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