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 => dest_path.join("data/part-00000.parquet"),
592        OutputFormat::ParquetAndIceberg => {
593            // Flat parquet is the primary dual-write artifact for ref().
594            if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
595                dest_path.to_path_buf()
596            } else {
597                dest_path.with_extension("parquet")
598            }
599        }
600        _ => dest_path.to_path_buf(),
601    }
602}
603
604/// Register a completed model so later SQL `ref('name')` resolves.
605///
606/// Never requires an in-memory `Vec<RecordBatch>` for the default lake-file backend.
607/// MemTable backend re-reads the written lake path (only used for small tables).
608async fn register_model_for_ref(
609    ctx: &SessionContext,
610    name: &str,
611    format: &OutputFormat,
612    dest_path: &Path,
613    backend: RefBackend,
614) -> Result<()> {
615    let _ = ctx.deregister_table(name);
616
617    match backend {
618        RefBackend::MemTable => {
619            let batches = match format {
620                OutputFormat::Parquet
621                | OutputFormat::ZeroCopyClone
622                | OutputFormat::Iceberg
623                | OutputFormat::ParquetAndIceberg => {
624                    let path = resolve_lake_read_path(format, dest_path)?;
625                    load_parquet_batches(&path).with_context(|| {
626                        format!(
627                            "E_RBT_REF_MEMTABLE: load {} for ref('{name}')",
628                            path.display()
629                        )
630                    })?
631                }
632                OutputFormat::Jsonl | OutputFormat::Csv => {
633                    // Register lake file then collect into MemTable (small tables only).
634                    Box::pin(async {
635                        // temporarily use lake registration path then table scan
636                        register_model_for_ref(ctx, name, format, dest_path, RefBackend::LakeFile)
637                            .await?;
638                        let df = ctx.table(name).await.map_err(|e| {
639                            anyhow::anyhow!("E_RBT_REF_MEMTABLE: table '{name}': {e}")
640                        })?;
641                        df.collect().await.map_err(|e| {
642                            anyhow::anyhow!("E_RBT_REF_MEMTABLE: collect '{name}': {e}")
643                        })
644                    })
645                    .await?
646                }
647            };
648            if batches.is_empty() {
649                bail!("E_RBT_REF_MEMTABLE: no batches for ref('{name}')");
650            }
651            let _ = ctx.deregister_table(name);
652            let schema = batches[0].schema();
653            let mem_table = MemTable::try_new(schema, vec![batches])
654                .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
655            ctx.register_table(name, Arc::new(mem_table))
656                .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
657        }
658        RefBackend::LakeFile => match format {
659            OutputFormat::Parquet
660            | OutputFormat::ZeroCopyClone
661            | OutputFormat::Iceberg
662            | OutputFormat::ParquetAndIceberg => {
663                let path = resolve_lake_read_path(format, dest_path)?;
664                ctx.register_parquet(
665                    name,
666                    path.to_str().unwrap_or_default(),
667                    ParquetReadOptions::default(),
668                )
669                .await
670                .map_err(|e| {
671                    anyhow::anyhow!(
672                        "E_RBT_REF_REGISTER: register_parquet {} for '{name}': {e}",
673                        path.display()
674                    )
675                })?;
676            }
677            OutputFormat::Jsonl => {
678                let p = dest_path.to_str().unwrap_or_default();
679                let opts = JsonReadOptions::default()
680                    .file_extension(".jsonl")
681                    .newline_delimited(true);
682                if let Err(e) = ctx.register_json(name, p, opts).await {
683                    tracing::debug!("jsonl register failed ({e}); retry default");
684                    ctx.register_json(name, p, JsonReadOptions::default())
685                        .await
686                        .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_json: {e}"))?;
687                }
688            }
689            OutputFormat::Csv => {
690                ctx.register_csv(
691                    name,
692                    dest_path.to_str().unwrap_or_default(),
693                    CsvReadOptions::default(),
694                )
695                .await
696                .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_csv: {e}"))?;
697            }
698        },
699    }
700    Ok(())
701}
702
703fn resolve_lake_read_path(format: &OutputFormat, dest_path: &Path) -> Result<PathBuf> {
704    let mut path = lake_read_path(format, dest_path);
705    if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
706        let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
707        if alt.exists() {
708            path = alt;
709        }
710    }
711    if !path.exists() {
712        bail!(
713            "E_RBT_REF_MISSING: lake file missing for ref(): expected {}",
714            path.display()
715        );
716    }
717    Ok(path)
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use crate::core::dag::{Materialization, ModelDag, OutputFormat};
724
725    #[tokio::test]
726    async fn test_engine_initialization() -> Result<()> {
727        let engine = TransformationEngine::new();
728        let df = engine.ctx.sql("SELECT 1 AS col").await?;
729        let batches = df.collect().await?;
730        assert_eq!(batches.len(), 1);
731        assert_eq!(batches[0].num_rows(), 1);
732        Ok(())
733    }
734
735    #[tokio::test]
736    async fn test_dag_execution_multi_format() -> Result<()> {
737        let temp_dir = tempfile::tempdir()?;
738        let engine = TransformationEngine::new();
739
740        let mut dag = ModelDag::new();
741        dag.add_model_with_format(
742            "users",
743            "SELECT 1 AS id, 'Alice' AS name",
744            Materialization::Table,
745            OutputFormat::Jsonl,
746            None,
747            "",
748        )?;
749        dag.add_model_with_format(
750            "active_users",
751            "SELECT * FROM {{ ref('users') }} WHERE id = 1",
752            Materialization::Table,
753            OutputFormat::Parquet,
754            None,
755            "",
756        )?;
757        dag.build_graph()?;
758
759        let summary = engine
760            .execute_dag(&dag, temp_dir.path(), temp_dir.path())
761            .await?;
762        assert_eq!(summary.models_executed, 2);
763        assert_eq!(summary.total_rows_produced, 2);
764        assert!(temp_dir.path().join("users.jsonl").exists());
765        assert!(temp_dir.path().join("active_users.parquet").exists());
766        Ok(())
767    }
768
769    #[tokio::test]
770    async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
771        let temp = tempfile::tempdir()?;
772        let bronze_dir = temp.path().join("lake/bronze");
773        std::fs::create_dir_all(&bronze_dir)?;
774        std::fs::write(
775            bronze_dir.join("raw_stock_trades.jsonl"),
776            r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
777{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
778"#,
779        )?;
780
781        let sql = r#"---
782source_format: jsonl
783scan_path: "lake/bronze/raw_stock_trades.jsonl"
784---
785SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
786"#;
787
788        let mut dag = ModelDag::new();
789        dag.add_model_with_format(
790            "stg_stock_trades",
791            sql,
792            Materialization::Table,
793            OutputFormat::Parquet,
794            Some(
795                temp.path()
796                    .join("lake/silver/stg_stock_trades.parquet")
797                    .to_string_lossy()
798                    .into(),
799            ),
800            "",
801        )?;
802        dag.build_graph()?;
803
804        let engine = TransformationEngine::new();
805        let summary = engine
806            .execute_dag(&dag, temp.path(), temp.path().join("out"))
807            .await?;
808        assert_eq!(summary.bronze_sources_registered, 1);
809        assert_eq!(summary.models_executed, 1);
810        assert_eq!(summary.total_rows_produced, 2);
811        assert!(temp
812            .path()
813            .join("lake/silver/stg_stock_trades.parquet")
814            .exists());
815        Ok(())
816    }
817
818    #[tokio::test]
819    async fn test_ref_via_parquet_reread_default() -> Result<()> {
820        use crate::core::project::{MaterializeConfig, RefStrategy};
821
822        let temp = tempfile::tempdir()?;
823        let mut dag = ModelDag::new();
824        dag.add_model_with_format(
825            "stg_a",
826            "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
827            Materialization::Table,
828            OutputFormat::Parquet,
829            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
830            "",
831        )?;
832        dag.add_model_with_format(
833            "tf_b",
834            "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
835            Materialization::Table,
836            OutputFormat::Parquet,
837            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
838            "",
839        )?;
840        dag.build_graph()?;
841
842        let mat = MaterializeConfig {
843            ref_strategy: RefStrategy::Parquet,
844            memtable_max_rows: 50_000,
845            ..Default::default()
846        };
847        let engine = TransformationEngine::new();
848        let summary = engine
849            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
850            .await?;
851        assert_eq!(summary.models_executed, 2);
852        assert_eq!(summary.total_rows_produced, 4);
853        assert!(temp.path().join("tf_b.parquet").exists());
854        Ok(())
855    }
856
857    #[tokio::test]
858    async fn test_ref_via_memtable_when_configured() -> Result<()> {
859        use crate::core::project::{MaterializeConfig, RefStrategy};
860
861        let temp = tempfile::tempdir()?;
862        let mut dag = ModelDag::new();
863        dag.add_model_with_format(
864            "stg_a",
865            "SELECT 1 AS id UNION ALL SELECT 2",
866            Materialization::Table,
867            OutputFormat::Parquet,
868            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
869            "",
870        )?;
871        dag.add_model_with_format(
872            "tf_b",
873            "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
874            Materialization::Table,
875            OutputFormat::Parquet,
876            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
877            "",
878        )?;
879        dag.build_graph()?;
880
881        let mat = MaterializeConfig {
882            ref_strategy: RefStrategy::Memtable,
883            memtable_max_rows: 50_000,
884            ..Default::default()
885        };
886        let engine = TransformationEngine::new();
887        let summary = engine
888            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
889            .await?;
890        assert_eq!(summary.models_executed, 2);
891        assert!(temp.path().join("tf_b.parquet").exists());
892        Ok(())
893    }
894
895    #[tokio::test]
896    async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
897        use crate::core::project::{MaterializeConfig, RefStrategy};
898
899        // Cutoff 1 → 2-row model must use lake re-read.
900        let temp = tempfile::tempdir()?;
901        let mut dag = ModelDag::new();
902        dag.add_model_with_format(
903            "stg_a",
904            "SELECT 1 AS id UNION ALL SELECT 2",
905            Materialization::Table,
906            OutputFormat::Parquet,
907            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
908            "",
909        )?;
910        dag.add_model_with_format(
911            "tf_b",
912            "SELECT * FROM {{ ref('stg_a') }}",
913            Materialization::Table,
914            OutputFormat::Parquet,
915            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
916            "",
917        )?;
918        dag.build_graph()?;
919
920        let mat = MaterializeConfig {
921            ref_strategy: RefStrategy::Memtable,
922            memtable_max_rows: 1,
923            ..Default::default()
924        };
925        let engine = TransformationEngine::new();
926        let summary = engine
927            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
928            .await?;
929        assert_eq!(summary.models_executed, 2);
930        assert_eq!(summary.total_rows_produced, 4);
931        Ok(())
932    }
933}