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, wap_publish, MaterializeWriteOptions,
18    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 write_opts =
479                    MaterializeWriteOptions::from_config(materialize, fail_on_error);
480                let mode = materialize.effective_mode();
481
482                // WAP: write to stage path first; publish only after audit.
483                let (write_path, wap_paths) = if let Some(ref run_id) = wap_run_id {
484                    if matches!(
485                        model.output_format,
486                        OutputFormat::Parquet | OutputFormat::ZeroCopyClone
487                    ) && model.materialization != Materialization::IncrementalAppend
488                    {
489                        let paths =
490                            WapModelPaths::for_model(project_dir, run_id, &model.name, &dest_path);
491                        if let Some(p) = paths.stage_path.parent() {
492                            std::fs::create_dir_all(p)?;
493                        }
494                        (paths.stage_path.clone(), Some(paths))
495                    } else {
496                        (dest_path.clone(), None)
497                    }
498                } else {
499                    (dest_path.clone(), None)
500                };
501
502                let (row_count, write_stats) = match (
503                    &model.materialization,
504                    &model.output_format,
505                    mode,
506                ) {
507                    (
508                        Materialization::IncrementalAppend,
509                        OutputFormat::Parquet | OutputFormat::ZeroCopyClone,
510                        MaterializeMode::Stream,
511                    ) => {
512                        let df = self.ctx.sql(&model.compiled_sql).await.with_context(|| {
513                            format!("E_RBT_SQL: model '{}'", model.name)
514                        })?;
515                        let stream = df.execute_stream().await?;
516                        let stats = materialize_incremental_append_stream(
517                            stream,
518                            &dest_path,
519                            &write_opts,
520                            &assertions,
521                        )
522                        .await
523                        .with_context(|| {
524                            format!(
525                                "E_RBT_INCREMENTAL: model '{}' append failed",
526                                model.name
527                            )
528                        })?;
529                        log_assertion_result(model, &stats, fail_on_error)?;
530                        (stats.rows, Some(stats))
531                    }
532                    (
533                        Materialization::IncrementalMerge,
534                        _,
535                        _,
536                    ) => {
537                        bail!(
538                            "E_RBT_INCREMENTAL: model '{}': incremental_merge is not implemented yet \
539                             (use incremental_append for part-file appends)",
540                            model.name
541                        );
542                    }
543                    (_, _, MaterializeMode::Stream) => {
544                        let stats = execute_model_stream(
545                            &self.ctx,
546                            model,
547                            &write_path,
548                            &write_opts,
549                            &assertions,
550                            fail_on_error,
551                        )
552                        .await?;
553                        (stats.rows, Some(stats))
554                    }
555                    (_, _, MaterializeMode::Collect) => {
556                        let rows = execute_model_collect(
557                            &self.ctx,
558                            model,
559                            &write_path,
560                            &write_opts,
561                            &assertions,
562                            fail_on_error,
563                        )
564                        .await?;
565                        (rows, None)
566                    }
567                };
568
569                // WAP publish after successful write+audit (stream assertions already applied).
570                if let Some(ref paths) = wap_paths {
571                    let validation = write_stats
572                        .as_ref()
573                        .map(|s| s.validation.clone())
574                        .unwrap_or_else(|| crate::testing::ValidationResult {
575                            total_rows: row_count,
576                            passed_assertions: 0,
577                            failed_assertions: 0,
578                            errors: Vec::new(),
579                        });
580                    wap_publish(paths, &model.name, row_count, &validation)?;
581                }
582
583                // Expose model for downstream {{ ref() }} per project materialize policy.
584                if row_count > 0
585                    || matches!(
586                        model.output_format,
587                        OutputFormat::Parquet
588                            | OutputFormat::Iceberg
589                            | OutputFormat::ParquetAndIceberg
590                            | OutputFormat::ZeroCopyClone
591                    )
592                {
593                    let backend = materialize.choose_ref_backend(row_count);
594                    if row_count > 0 {
595                        let ref_path = if model.materialization == Materialization::IncrementalAppend
596                            && matches!(
597                                model.output_format,
598                                OutputFormat::Parquet | OutputFormat::ZeroCopyClone
599                            ) {
600                            incremental_ref_path(&dest_path)
601                        } else {
602                            dest_path.clone()
603                        };
604                        register_model_for_ref(
605                            &self.ctx,
606                            &model.name,
607                            &model.output_format,
608                            &ref_path,
609                            backend,
610                        )
611                        .await
612                        .with_context(|| {
613                            format!(
614                                "E_RBT_REF_REGISTER: model '{}' (backend={:?}, rows={}, mode={:?})",
615                                model.name, backend, row_count, mode
616                            )
617                        })?;
618                        tracing::debug!(
619                            model = %model.name,
620                            rows = row_count,
621                            ?backend,
622                            ?mode,
623                            strategy = ?materialize.ref_strategy,
624                            mat = ?model.materialization,
625                            "registered model for ref()"
626                        );
627                    }
628                }
629
630                models_executed += 1;
631                total_rows_produced += row_count;
632                model_results.push(ModelRunResult {
633                    name: model.name.clone(),
634                    rows: row_count,
635                    output_path: Some(dest_path.display().to_string()),
636                });
637            }
638        }
639
640        let finished = now_unix_ms();
641        let mut summary = DagExecutionSummary {
642            models_executed,
643            total_rows_produced,
644            bronze_sources_registered,
645            skipped: false,
646            skip_reason: None,
647            bronze_fingerprint: Some(fp.clone()),
648            run_id: Some(run_id.clone()),
649            receipt_path: None,
650            model_results: model_results.clone(),
651        };
652
653        if scope.write_receipt {
654            let receipt = RunReceipt {
655                schema_version: RunReceipt::SCHEMA_VERSION,
656                run_id: run_id.clone(),
657                project: config.name.clone(),
658                package_version: crate::VERSION.into(),
659                contract_version: contract,
660                scope_key,
661                vars: scope.vars.clone(),
662                status: RunStatus::Ok,
663                skipped: false,
664                skip_reason: None,
665                bronze_fingerprint: fp,
666                models_executed,
667                total_rows: total_rows_produced,
668                bronze_sources: bronze_sources_registered,
669                model_results,
670                started_unix_ms: started,
671                finished_unix_ms: finished,
672                wall_ms: finished.saturating_sub(started),
673                error: None,
674            };
675            summary.receipt_path = Some(receipt.write(project_dir)?);
676        }
677
678        Ok(summary)
679    }
680}
681
682/// Build frontmatter assertion list + fail-on-error policy for a model.
683fn model_assertions(model: &ModelNode) -> (Vec<Assertion>, bool) {
684    let mut fail_on_error = true;
685    let assertions = if let Some(fm) = model.frontmatter.as_ref() {
686        if let Some(tests) = fm.tests.as_ref() {
687            fail_on_error = tests.should_fail_on_error();
688            if tests.is_empty() {
689                Vec::new()
690            } else {
691                let unique = tests
692                    .unique
693                    .clone()
694                    .or_else(|| fm.unique_key.clone())
695                    .or_else(|| fm.grain.clone());
696                assertions_from_model_tests(
697                    tests.not_null.as_deref(),
698                    unique.as_deref(),
699                    tests.accepted_values.as_ref(),
700                )
701            }
702        } else if let Some(uk) = fm
703            .unique_key
704            .as_ref()
705            .or(fm.grain.as_ref())
706            .filter(|v| !v.is_empty())
707        {
708            fail_on_error = true;
709            assertions_from_model_tests(None, Some(uk.as_slice()), None)
710        } else {
711            Vec::new()
712        }
713    } else {
714        Vec::new()
715    };
716    (assertions, fail_on_error)
717}
718
719fn log_assertion_result(
720    model: &ModelNode,
721    stats: &StreamWriteStats,
722    fail_on_error: bool,
723) -> Result<()> {
724    if stats.validation.failed_assertions > 0 {
725        let msg = format!(
726            "model '{}' failed {} test(s): {}",
727            model.name,
728            stats.validation.failed_assertions,
729            stats.validation.errors.join("; ")
730        );
731        if fail_on_error {
732            bail!("{msg}");
733        }
734        tracing::warn!("{msg}");
735    } else if stats.validation.passed_assertions > 0 {
736        tracing::info!(
737            "model '{}': {} assertion(s) passed ({} rows)",
738            model.name,
739            stats.validation.passed_assertions,
740            stats.rows
741        );
742    }
743    Ok(())
744}
745
746async fn execute_model_stream(
747    ctx: &SessionContext,
748    model: &ModelNode,
749    dest_path: &Path,
750    write_opts: &MaterializeWriteOptions,
751    assertions: &[Assertion],
752    fail_on_error: bool,
753) -> Result<StreamWriteStats> {
754    let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
755        format!(
756            "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
757            model.name, model.compiled_sql
758        )
759    })?;
760    let stream = df.execute_stream().await.with_context(|| {
761        format!(
762            "E_RBT_SQL: execute_stream failed for model '{}'",
763            model.name
764        )
765    })?;
766
767    let stats = materialize_stream(
768        stream,
769        &model.output_format,
770        dest_path,
771        write_opts,
772        assertions,
773    )
774    .await
775    .with_context(|| {
776        format!(
777            "E_RBT_MATERIALIZE: stream write failed for model '{}' → {}",
778            model.name,
779            dest_path.display()
780        )
781    })?;
782
783    if stats.validation.failed_assertions > 0 {
784        let msg = format!(
785            "model '{}' failed {} test(s): {}",
786            model.name,
787            stats.validation.failed_assertions,
788            stats.validation.errors.join("; ")
789        );
790        if fail_on_error {
791            bail!("{msg}");
792        }
793        tracing::warn!("{msg}");
794    } else if !assertions.is_empty() {
795        tracing::info!(
796            "model '{}': {} assertion(s) passed ({} rows, {} batches, stream)",
797            model.name,
798            stats.validation.passed_assertions,
799            stats.rows,
800            stats.batches
801        );
802    } else {
803        tracing::debug!(
804            model = %model.name,
805            rows = stats.rows,
806            batches = stats.batches,
807            bytes = stats.bytes_written,
808            "stream materialize complete"
809        );
810    }
811    Ok(stats)
812}
813
814async fn execute_model_collect(
815    ctx: &SessionContext,
816    model: &ModelNode,
817    dest_path: &Path,
818    write_opts: &MaterializeWriteOptions,
819    assertions: &[Assertion],
820    fail_on_error: bool,
821) -> Result<usize> {
822    let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
823        format!(
824            "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
825            model.name, model.compiled_sql
826        )
827    })?;
828    let batches = df.collect().await.with_context(|| {
829        format!(
830            "E_RBT_SQL: collect failed for model '{}'",
831            model.name
832        )
833    })?;
834    let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
835
836    MultiFormatWriter::write_batches(&batches, &model.output_format, dest_path)?;
837    // Prefer atomic parquet path for primary formats already handled inside MultiFormatWriter.
838
839    if !assertions.is_empty() {
840        let result = RecordBatchValidator::validate_batches(&batches, assertions);
841        if result.failed_assertions > 0 {
842            let msg = format!(
843                "model '{}' failed {} test(s): {}",
844                model.name,
845                result.failed_assertions,
846                result.errors.join("; ")
847            );
848            if fail_on_error {
849                bail!("{msg}");
850            }
851            tracing::warn!("{msg}");
852        } else {
853            tracing::info!(
854                "model '{}': {} assertion(s) passed ({} rows, collect)",
855                model.name,
856                result.passed_assertions,
857                result.total_rows
858            );
859        }
860    }
861
862    let _ = write_opts; // reserved for collect-path parquet props if we unify further
863    Ok(row_count)
864}
865
866/// Path used to re-read a model from the lake after materialize.
867fn lake_read_path(format: &OutputFormat, dest_path: &Path) -> PathBuf {
868    match format {
869        OutputFormat::Iceberg => {
870            // Catalog SoR: prefer `.rbt_iceberg_data` hint, then any parquet under table root.
871            let hint = dest_path.join(".rbt_iceberg_data");
872            if let Ok(p) = std::fs::read_to_string(&hint) {
873                let p = PathBuf::from(p.trim());
874                if p.exists() {
875                    return p;
876                }
877            }
878            let preferred = dest_path.join("data/part-00000.parquet");
879            if preferred.exists() {
880                preferred
881            } else if let Some(p) = find_first_parquet_under(dest_path) {
882                p
883            } else {
884                preferred
885            }
886        }
887        OutputFormat::ParquetAndIceberg => {
888            // Flat parquet is the primary dual-write artifact for ref().
889            if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
890                dest_path.to_path_buf()
891            } else {
892                dest_path.with_extension("parquet")
893            }
894        }
895        _ => dest_path.to_path_buf(),
896    }
897}
898
899fn find_first_parquet_under(dir: &Path) -> Option<PathBuf> {
900    let mut stack = vec![dir.to_path_buf()];
901    while let Some(d) = stack.pop() {
902        let entries = std::fs::read_dir(&d).ok()?;
903        for e in entries.flatten() {
904            let p = e.path();
905            if p.is_dir() {
906                stack.push(p);
907            } else if p.extension().and_then(|x| x.to_str()) == Some("parquet") {
908                return Some(p);
909            }
910        }
911    }
912    None
913}
914
915/// Register a completed model so later SQL `ref('name')` resolves.
916///
917/// Never requires an in-memory `Vec<RecordBatch>` for the default lake-file backend.
918/// MemTable backend re-reads the written lake path (only used for small tables).
919async fn register_model_for_ref(
920    ctx: &SessionContext,
921    name: &str,
922    format: &OutputFormat,
923    dest_path: &Path,
924    backend: RefBackend,
925) -> Result<()> {
926    let _ = ctx.deregister_table(name);
927
928    match backend {
929        RefBackend::MemTable => {
930            let batches = match format {
931                OutputFormat::Parquet
932                | OutputFormat::ZeroCopyClone
933                | OutputFormat::Iceberg
934                | OutputFormat::ParquetAndIceberg => {
935                    let path = resolve_lake_read_path(format, dest_path)?;
936                    load_parquet_batches(&path).with_context(|| {
937                        format!(
938                            "E_RBT_REF_MEMTABLE: load {} for ref('{name}')",
939                            path.display()
940                        )
941                    })?
942                }
943                OutputFormat::Jsonl | OutputFormat::Csv => {
944                    // Register lake file then collect into MemTable (small tables only).
945                    Box::pin(async {
946                        // temporarily use lake registration path then table scan
947                        register_model_for_ref(ctx, name, format, dest_path, RefBackend::LakeFile)
948                            .await?;
949                        let df = ctx.table(name).await.map_err(|e| {
950                            anyhow::anyhow!("E_RBT_REF_MEMTABLE: table '{name}': {e}")
951                        })?;
952                        df.collect().await.map_err(|e| {
953                            anyhow::anyhow!("E_RBT_REF_MEMTABLE: collect '{name}': {e}")
954                        })
955                    })
956                    .await?
957                }
958            };
959            if batches.is_empty() {
960                bail!("E_RBT_REF_MEMTABLE: no batches for ref('{name}')");
961            }
962            let _ = ctx.deregister_table(name);
963            let schema = batches[0].schema();
964            let mem_table = MemTable::try_new(schema, vec![batches])
965                .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
966            ctx.register_table(name, Arc::new(mem_table))
967                .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
968        }
969        RefBackend::LakeFile => match format {
970            OutputFormat::Parquet
971            | OutputFormat::ZeroCopyClone
972            | OutputFormat::Iceberg
973            | OutputFormat::ParquetAndIceberg => {
974                let path = resolve_lake_read_path(format, dest_path)?;
975                ctx.register_parquet(
976                    name,
977                    path.to_str().unwrap_or_default(),
978                    ParquetReadOptions::default(),
979                )
980                .await
981                .map_err(|e| {
982                    anyhow::anyhow!(
983                        "E_RBT_REF_REGISTER: register_parquet {} for '{name}': {e}",
984                        path.display()
985                    )
986                })?;
987            }
988            OutputFormat::Jsonl => {
989                let p = dest_path.to_str().unwrap_or_default();
990                let opts = JsonReadOptions::default()
991                    .file_extension(".jsonl")
992                    .newline_delimited(true);
993                if let Err(e) = ctx.register_json(name, p, opts).await {
994                    tracing::debug!("jsonl register failed ({e}); retry default");
995                    ctx.register_json(name, p, JsonReadOptions::default())
996                        .await
997                        .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_json: {e}"))?;
998                }
999            }
1000            OutputFormat::Csv => {
1001                ctx.register_csv(
1002                    name,
1003                    dest_path.to_str().unwrap_or_default(),
1004                    CsvReadOptions::default(),
1005                )
1006                .await
1007                .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_csv: {e}"))?;
1008            }
1009        },
1010    }
1011    Ok(())
1012}
1013
1014fn resolve_lake_read_path(format: &OutputFormat, dest_path: &Path) -> Result<PathBuf> {
1015    let mut path = lake_read_path(format, dest_path);
1016    if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
1017        let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
1018        if alt.exists() {
1019            path = alt;
1020        }
1021    }
1022    if !path.exists() {
1023        bail!(
1024            "E_RBT_REF_MISSING: lake file missing for ref(): expected {}",
1025            path.display()
1026        );
1027    }
1028    Ok(path)
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034    use crate::core::dag::{Materialization, ModelDag, OutputFormat};
1035
1036    #[tokio::test]
1037    async fn test_engine_initialization() -> Result<()> {
1038        let engine = TransformationEngine::new();
1039        let df = engine.ctx.sql("SELECT 1 AS col").await?;
1040        let batches = df.collect().await?;
1041        assert_eq!(batches.len(), 1);
1042        assert_eq!(batches[0].num_rows(), 1);
1043        Ok(())
1044    }
1045
1046    #[tokio::test]
1047    async fn test_dag_execution_multi_format() -> Result<()> {
1048        let temp_dir = tempfile::tempdir()?;
1049        let engine = TransformationEngine::new();
1050
1051        let mut dag = ModelDag::new();
1052        dag.add_model_with_format(
1053            "users",
1054            "SELECT 1 AS id, 'Alice' AS name",
1055            Materialization::Table,
1056            OutputFormat::Jsonl,
1057            None,
1058            "",
1059        )?;
1060        dag.add_model_with_format(
1061            "active_users",
1062            "SELECT * FROM {{ ref('users') }} WHERE id = 1",
1063            Materialization::Table,
1064            OutputFormat::Parquet,
1065            None,
1066            "",
1067        )?;
1068        dag.build_graph()?;
1069
1070        let summary = engine
1071            .execute_dag(&dag, temp_dir.path(), temp_dir.path())
1072            .await?;
1073        assert_eq!(summary.models_executed, 2);
1074        assert_eq!(summary.total_rows_produced, 2);
1075        assert!(temp_dir.path().join("users.jsonl").exists());
1076        assert!(temp_dir.path().join("active_users.parquet").exists());
1077        Ok(())
1078    }
1079
1080    #[tokio::test]
1081    async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
1082        let temp = tempfile::tempdir()?;
1083        let bronze_dir = temp.path().join("lake/bronze");
1084        std::fs::create_dir_all(&bronze_dir)?;
1085        std::fs::write(
1086            bronze_dir.join("raw_stock_trades.jsonl"),
1087            r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
1088{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
1089"#,
1090        )?;
1091
1092        let sql = r#"---
1093source_format: jsonl
1094scan_path: "lake/bronze/raw_stock_trades.jsonl"
1095---
1096SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
1097"#;
1098
1099        let mut dag = ModelDag::new();
1100        dag.add_model_with_format(
1101            "stg_stock_trades",
1102            sql,
1103            Materialization::Table,
1104            OutputFormat::Parquet,
1105            Some(
1106                temp.path()
1107                    .join("lake/silver/stg_stock_trades.parquet")
1108                    .to_string_lossy()
1109                    .into(),
1110            ),
1111            "",
1112        )?;
1113        dag.build_graph()?;
1114
1115        let engine = TransformationEngine::new();
1116        let summary = engine
1117            .execute_dag(&dag, temp.path(), temp.path().join("out"))
1118            .await?;
1119        assert_eq!(summary.bronze_sources_registered, 1);
1120        assert_eq!(summary.models_executed, 1);
1121        assert_eq!(summary.total_rows_produced, 2);
1122        assert!(temp
1123            .path()
1124            .join("lake/silver/stg_stock_trades.parquet")
1125            .exists());
1126        Ok(())
1127    }
1128
1129    #[tokio::test]
1130    async fn test_ref_via_parquet_reread_default() -> Result<()> {
1131        use crate::core::project::{MaterializeConfig, RefStrategy};
1132
1133        let temp = tempfile::tempdir()?;
1134        let mut dag = ModelDag::new();
1135        dag.add_model_with_format(
1136            "stg_a",
1137            "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
1138            Materialization::Table,
1139            OutputFormat::Parquet,
1140            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1141            "",
1142        )?;
1143        dag.add_model_with_format(
1144            "tf_b",
1145            "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
1146            Materialization::Table,
1147            OutputFormat::Parquet,
1148            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1149            "",
1150        )?;
1151        dag.build_graph()?;
1152
1153        let mat = MaterializeConfig {
1154            ref_strategy: RefStrategy::Parquet,
1155            memtable_max_rows: 50_000,
1156            ..Default::default()
1157        };
1158        let engine = TransformationEngine::new();
1159        let summary = engine
1160            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1161            .await?;
1162        assert_eq!(summary.models_executed, 2);
1163        assert_eq!(summary.total_rows_produced, 4);
1164        assert!(temp.path().join("tf_b.parquet").exists());
1165        Ok(())
1166    }
1167
1168    #[tokio::test]
1169    async fn test_ref_via_memtable_when_configured() -> Result<()> {
1170        use crate::core::project::{MaterializeConfig, RefStrategy};
1171
1172        let temp = tempfile::tempdir()?;
1173        let mut dag = ModelDag::new();
1174        dag.add_model_with_format(
1175            "stg_a",
1176            "SELECT 1 AS id UNION ALL SELECT 2",
1177            Materialization::Table,
1178            OutputFormat::Parquet,
1179            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1180            "",
1181        )?;
1182        dag.add_model_with_format(
1183            "tf_b",
1184            "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
1185            Materialization::Table,
1186            OutputFormat::Parquet,
1187            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1188            "",
1189        )?;
1190        dag.build_graph()?;
1191
1192        let mat = MaterializeConfig {
1193            ref_strategy: RefStrategy::Memtable,
1194            memtable_max_rows: 50_000,
1195            ..Default::default()
1196        };
1197        let engine = TransformationEngine::new();
1198        let summary = engine
1199            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1200            .await?;
1201        assert_eq!(summary.models_executed, 2);
1202        assert!(temp.path().join("tf_b.parquet").exists());
1203        Ok(())
1204    }
1205
1206    #[tokio::test]
1207    async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
1208        use crate::core::project::{MaterializeConfig, RefStrategy};
1209
1210        // Cutoff 1 → 2-row model must use lake re-read.
1211        let temp = tempfile::tempdir()?;
1212        let mut dag = ModelDag::new();
1213        dag.add_model_with_format(
1214            "stg_a",
1215            "SELECT 1 AS id UNION ALL SELECT 2",
1216            Materialization::Table,
1217            OutputFormat::Parquet,
1218            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1219            "",
1220        )?;
1221        dag.add_model_with_format(
1222            "tf_b",
1223            "SELECT * FROM {{ ref('stg_a') }}",
1224            Materialization::Table,
1225            OutputFormat::Parquet,
1226            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1227            "",
1228        )?;
1229        dag.build_graph()?;
1230
1231        let mat = MaterializeConfig {
1232            ref_strategy: RefStrategy::Memtable,
1233            memtable_max_rows: 1,
1234            ..Default::default()
1235        };
1236        let engine = TransformationEngine::new();
1237        let summary = engine
1238            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1239            .await?;
1240        assert_eq!(summary.models_executed, 2);
1241        assert_eq!(summary.total_rows_produced, 4);
1242        Ok(())
1243    }
1244}