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