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