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(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#[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 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 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 pub fn clear_project_cache(&self) {
122 if let Ok(mut guard) = self.project_cache.lock() {
123 *guard = None;
124 }
125 }
126
127 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 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 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 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 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 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 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 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 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 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 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
431fn 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 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; Ok(row_count)
586}
587
588fn 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 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
604async 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 Box::pin(async {
635 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 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}