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