1pub 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#[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#[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 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 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 pub fn clear_project_cache(&self) {
111 if let Ok(mut guard) = self.project_cache.lock() {
112 *guard = None;
113 }
114 }
115
116 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 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 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 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 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 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 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 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
310fn 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 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; Ok(row_count)
465}
466
467fn 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 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
483async 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 Box::pin(async {
514 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 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}