1pub mod bronze;
4pub mod udf;
5
6use crate::core::dag::{Materialization, ModelDag, ModelNode, OutputFormat};
7use crate::core::project::{
8 MaterializeConfig, MaterializeMode, RbtProjectConfig, RefBackend,
9};
10use crate::materializer::{
11 incremental_ref_path, load_parquet_batches, materialize_incremental_append_stream,
12 materialize_stream, new_wap_run_id, sibling_iceberg_dir, wap_publish, MaterializeWriteOptions,
13 MultiFormatWriter, StreamWriteStats, WapModelPaths,
14};
15use crate::engine::udf::register_builtin_udfs;
16use crate::testing::{assertions_from_model_tests, Assertion, RecordBatchValidator};
17use anyhow::{bail, Context, Result};
18use datafusion::datasource::MemTable;
19use datafusion::execution::context::SessionContext;
20use datafusion::physical_plan::SendableRecordBatchStream;
21use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
22use iceberg::Catalog;
23use iceberg_datafusion::IcebergCatalogProvider;
24use std::collections::HashSet;
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex};
27
28pub use bronze::{
29 register_bronze_for_model, register_bronze_sources_for_dag, BronzeRegistrationMode,
30 BronzeSourceMeta, BronzeTableProvider,
31};
32
33#[derive(Debug, Clone)]
35pub struct DagExecutionSummary {
36 pub models_executed: usize,
37 pub total_rows_produced: usize,
38 pub bronze_sources_registered: usize,
39}
40
41#[derive(Debug, Clone)]
43pub struct PreviewResult {
44 pub model: String,
45 pub compiled_sql: String,
46 pub limit: usize,
47 pub rows: usize,
48 pub batches: Vec<arrow::record_batch::RecordBatch>,
49 pub ancestors_executed: usize,
50}
51
52#[derive(Default)]
54pub struct RbtEngineBuilder {
55 catalogs: Vec<(String, Arc<dyn Catalog>)>,
56}
57
58impl RbtEngineBuilder {
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
64 self.catalogs.push((name.into(), catalog));
65 self
66 }
67
68 pub async fn build(self) -> Result<TransformationEngine> {
69 let engine = TransformationEngine::new();
70 for (name, cat) in self.catalogs {
71 engine.register_iceberg_catalog(&name, cat).await?;
72 }
73 Ok(engine)
74 }
75}
76
77pub struct TransformationEngine {
78 pub ctx: SessionContext,
79 project_cache: Mutex<Option<(PathBuf, Arc<RbtProjectConfig>)>>,
83}
84
85impl Default for TransformationEngine {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl TransformationEngine {
92 pub fn new() -> Self {
93 let ctx = SessionContext::new();
94 if let Err(e) = register_builtin_udfs(&ctx) {
95 tracing::warn!("E_RBT_UDF: failed to register builtins: {e}");
96 }
97 Self {
98 ctx,
99 project_cache: Mutex::new(None),
100 }
101 }
102
103 pub fn load_project_config(&self, project_dir: &Path) -> Result<Arc<RbtProjectConfig>> {
105 let key = project_dir
106 .canonicalize()
107 .unwrap_or_else(|_| project_dir.to_path_buf());
108 let mut guard = self
109 .project_cache
110 .lock()
111 .map_err(|_| anyhow::anyhow!("E_RBT_ENGINE: project config cache lock poisoned"))?;
112 if let Some((ref cached_dir, ref cfg)) = *guard {
113 if *cached_dir == key {
114 return Ok(Arc::clone(cfg));
115 }
116 }
117 let cfg = Arc::new(RbtProjectConfig::load(project_dir).with_context(|| {
118 format!(
119 "E_RBT_PROJECT_LOAD: failed loading rbt_project.yml under {}",
120 project_dir.display()
121 )
122 })?);
123 *guard = Some((key, Arc::clone(&cfg)));
124 Ok(cfg)
125 }
126
127 pub fn clear_project_cache(&self) {
129 if let Ok(mut guard) = self.project_cache.lock() {
130 *guard = None;
131 }
132 }
133
134 pub async fn register_iceberg_catalog(
136 &self,
137 catalog_name: &str,
138 catalog: Arc<dyn Catalog>,
139 ) -> Result<()> {
140 tracing::info!(
141 "Registering Iceberg catalog '{}' into DataFusion SessionContext",
142 catalog_name
143 );
144 let provider = IcebergCatalogProvider::try_new(catalog).await?;
145 self.ctx.register_catalog(catalog_name, Arc::new(provider));
146 Ok(())
147 }
148
149 pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
151 tracing::info!(
152 "Executing SQL transform via Apache DataFusion engine: {}",
153 sql
154 );
155 let df = self.ctx.sql(sql).await?;
156 let stream = df.execute_stream().await?;
157 Ok(stream)
158 }
159
160 pub async fn execute_dag(
168 &self,
169 dag: &ModelDag,
170 project_dir: impl AsRef<Path>,
171 output_dir: impl AsRef<Path>,
172 ) -> Result<DagExecutionSummary> {
173 let project_dir = project_dir.as_ref();
174 let config = self.load_project_config(project_dir)?;
175 self.execute_dag_with_config(dag, project_dir, output_dir, &config)
176 .await
177 }
178
179 pub async fn execute_dag_with_materialize(
181 &self,
182 dag: &ModelDag,
183 project_dir: impl AsRef<Path>,
184 output_dir: impl AsRef<Path>,
185 materialize: &MaterializeConfig,
186 ) -> Result<DagExecutionSummary> {
187 let project_dir = project_dir.as_ref();
188 let mut config = (*self.load_project_config(project_dir)?).clone();
189 config.materialize = materialize.clone();
190 self.execute_dag_with_config(dag, project_dir, output_dir, &config)
191 .await
192 }
193
194 pub async fn preview_model(
199 &self,
200 full_dag: &ModelDag,
201 project_dir: impl AsRef<Path>,
202 output_dir: impl AsRef<Path>,
203 model_name: &str,
204 limit: usize,
205 ) -> Result<PreviewResult> {
206 let project_dir = project_dir.as_ref();
207 let config = self.load_project_config(project_dir)?;
208 let limit = limit.clamp(1, 10_000);
209
210 let sub = full_dag
211 .apply_select(Some(model_name), crate::core::SelectMode::Execute)
212 .with_context(|| {
213 format!(
214 "E_RBT_PREVIEW: cannot select model '{model_name}' (check name / --select)"
215 )
216 })?;
217 let seq = sub.topological_sequence()?;
218 let target = seq
219 .iter()
220 .find(|m| m.name == model_name)
221 .cloned()
222 .ok_or_else(|| {
223 anyhow::anyhow!("E_RBT_PREVIEW: model '{model_name}' not found in project DAG")
224 })?;
225 self.preview_model_inner(full_dag, project_dir, output_dir, &target, limit, &config)
226 .await
227 }
228
229 async fn preview_model_inner(
230 &self,
231 full_dag: &ModelDag,
232 project_dir: &Path,
233 output_dir: impl AsRef<Path>,
234 target: &ModelNode,
235 limit: usize,
236 config: &RbtProjectConfig,
237 ) -> Result<PreviewResult> {
238 let output_dir = output_dir.as_ref();
239 let sub = full_dag
240 .apply_select(Some(&target.name), crate::core::SelectMode::Execute)?;
241 let seq = sub.topological_sequence()?;
242 let ancestor_names: Vec<String> = seq
243 .iter()
244 .map(|m| m.name.clone())
245 .filter(|n| n != &target.name)
246 .collect();
247
248 let mut ancestors_executed = 0usize;
249 if !ancestor_names.is_empty() {
250 let anc_select = ancestor_names.join(",");
251 let anc_dag = full_dag
252 .apply_select(Some(&anc_select), crate::core::SelectMode::Execute)
253 .context("E_RBT_PREVIEW: ancestor select failed")?;
254 let summary = self
255 .execute_dag_with_config(&anc_dag, project_dir, output_dir, config)
256 .await
257 .context("E_RBT_PREVIEW: ancestor materialize failed")?;
258 ancestors_executed = summary.models_executed;
259 } else {
260 let mut registered = HashSet::new();
262 register_bronze_sources_for_dag(
263 &self.ctx,
264 &sub,
265 project_dir,
266 &mut registered,
267 config,
268 )
269 .await
270 .context("E_RBT_PREVIEW: bronze registration failed")?;
271 }
272
273 let mut registered = HashSet::new();
275 register_bronze_for_model(&self.ctx, target, project_dir, &mut registered, config)
276 .await?;
277
278 let preview_sql = format!(
279 "SELECT * FROM (\n{}\n) AS _rbt_preview LIMIT {}",
280 target.compiled_sql.trim().trim_end_matches(';'),
281 limit
282 );
283 let df = self.ctx.sql(&preview_sql).await.with_context(|| {
284 format!(
285 "E_RBT_PREVIEW: SQL failed for model '{}': {preview_sql}",
286 target.name
287 )
288 })?;
289 let batches = df.collect().await.with_context(|| {
290 format!("E_RBT_PREVIEW: collect failed for model '{}'", target.name)
291 })?;
292 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
293
294 Ok(PreviewResult {
295 model: target.name.clone(),
296 compiled_sql: target.compiled_sql.clone(),
297 limit,
298 rows,
299 batches,
300 ancestors_executed,
301 })
302 }
303
304 pub async fn execute_dag_with_config(
306 &self,
307 dag: &ModelDag,
308 project_dir: impl AsRef<Path>,
309 output_dir: impl AsRef<Path>,
310 config: &RbtProjectConfig,
311 ) -> Result<DagExecutionSummary> {
312 let project_dir = project_dir.as_ref();
313 let output_base = output_dir.as_ref();
314 let materialize = &config.materialize;
315 tokio::fs::create_dir_all(output_base).await?;
316
317 let mut registered = HashSet::new();
318 let bronze_sources_registered =
319 register_bronze_sources_for_dag(&self.ctx, dag, project_dir, &mut registered, config)
320 .await
321 .context("frontmatter-driven bronze registration failed")?;
322
323 let tiers = dag.execution_tiers()?;
324 let mut models_executed = 0;
325 let mut total_rows_produced = 0;
326 let wap_run_id = if materialize.wap {
327 Some(new_wap_run_id())
328 } else {
329 None
330 };
331
332 for (tier_idx, tier) in tiers.iter().enumerate() {
333 tracing::info!(
334 "Executing DAG Tier {} with {} parallel models",
335 tier_idx,
336 tier.len()
337 );
338
339 for model in tier {
340 tracing::info!("Executing model '{}'...", model.name);
341
342 register_bronze_for_model(&self.ctx, model, project_dir, &mut registered, config)
344 .await?;
345
346 let dest_path = model
347 .output_path
348 .as_ref()
349 .map(PathBuf::from)
350 .unwrap_or_else(|| match model.output_format {
351 OutputFormat::Iceberg => output_base.join(&model.name),
352 OutputFormat::Jsonl => output_base.join(format!("{}.jsonl", model.name)),
353 OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
354 _ => output_base.join(format!("{}.parquet", model.name)),
355 });
356
357 if let Some(parent) = dest_path.parent() {
358 std::fs::create_dir_all(parent)?;
359 }
360
361 let (assertions, fail_on_error) = model_assertions(model);
362 let write_opts =
363 MaterializeWriteOptions::from_config(materialize, fail_on_error);
364 let mode = materialize.effective_mode();
365
366 let (write_path, wap_paths) = if let Some(ref run_id) = wap_run_id {
368 if matches!(
369 model.output_format,
370 OutputFormat::Parquet | OutputFormat::ZeroCopyClone
371 ) && model.materialization != Materialization::IncrementalAppend
372 {
373 let paths =
374 WapModelPaths::for_model(project_dir, run_id, &model.name, &dest_path);
375 if let Some(p) = paths.stage_path.parent() {
376 std::fs::create_dir_all(p)?;
377 }
378 (paths.stage_path.clone(), Some(paths))
379 } else {
380 (dest_path.clone(), None)
381 }
382 } else {
383 (dest_path.clone(), None)
384 };
385
386 let (row_count, write_stats) = match (
387 &model.materialization,
388 &model.output_format,
389 mode,
390 ) {
391 (
392 Materialization::IncrementalAppend,
393 OutputFormat::Parquet | OutputFormat::ZeroCopyClone,
394 MaterializeMode::Stream,
395 ) => {
396 let df = self.ctx.sql(&model.compiled_sql).await.with_context(|| {
397 format!("E_RBT_SQL: model '{}'", model.name)
398 })?;
399 let stream = df.execute_stream().await?;
400 let stats = materialize_incremental_append_stream(
401 stream,
402 &dest_path,
403 &write_opts,
404 &assertions,
405 )
406 .await
407 .with_context(|| {
408 format!(
409 "E_RBT_INCREMENTAL: model '{}' append failed",
410 model.name
411 )
412 })?;
413 log_assertion_result(model, &stats, fail_on_error)?;
414 (stats.rows, Some(stats))
415 }
416 (
417 Materialization::IncrementalMerge,
418 _,
419 _,
420 ) => {
421 bail!(
422 "E_RBT_INCREMENTAL: model '{}': incremental_merge is not implemented yet \
423 (use incremental_append for part-file appends)",
424 model.name
425 );
426 }
427 (_, _, MaterializeMode::Stream) => {
428 let stats = execute_model_stream(
429 &self.ctx,
430 model,
431 &write_path,
432 &write_opts,
433 &assertions,
434 fail_on_error,
435 )
436 .await?;
437 (stats.rows, Some(stats))
438 }
439 (_, _, MaterializeMode::Collect) => {
440 let rows = execute_model_collect(
441 &self.ctx,
442 model,
443 &write_path,
444 &write_opts,
445 &assertions,
446 fail_on_error,
447 )
448 .await?;
449 (rows, None)
450 }
451 };
452
453 if let Some(ref paths) = wap_paths {
455 let validation = write_stats
456 .as_ref()
457 .map(|s| s.validation.clone())
458 .unwrap_or_else(|| crate::testing::ValidationResult {
459 total_rows: row_count,
460 passed_assertions: 0,
461 failed_assertions: 0,
462 errors: Vec::new(),
463 });
464 wap_publish(paths, &model.name, row_count, &validation)?;
465 }
466
467 if row_count > 0
469 || matches!(
470 model.output_format,
471 OutputFormat::Parquet
472 | OutputFormat::Iceberg
473 | OutputFormat::ParquetAndIceberg
474 | OutputFormat::ZeroCopyClone
475 )
476 {
477 let backend = materialize.choose_ref_backend(row_count);
478 if row_count > 0 {
479 let ref_path = if model.materialization == Materialization::IncrementalAppend
480 && matches!(
481 model.output_format,
482 OutputFormat::Parquet | OutputFormat::ZeroCopyClone
483 ) {
484 incremental_ref_path(&dest_path)
485 } else {
486 dest_path.clone()
487 };
488 register_model_for_ref(
489 &self.ctx,
490 &model.name,
491 &model.output_format,
492 &ref_path,
493 backend,
494 )
495 .await
496 .with_context(|| {
497 format!(
498 "E_RBT_REF_REGISTER: model '{}' (backend={:?}, rows={}, mode={:?})",
499 model.name, backend, row_count, mode
500 )
501 })?;
502 tracing::debug!(
503 model = %model.name,
504 rows = row_count,
505 ?backend,
506 ?mode,
507 strategy = ?materialize.ref_strategy,
508 mat = ?model.materialization,
509 "registered model for ref()"
510 );
511 }
512 }
513
514 models_executed += 1;
515 total_rows_produced += row_count;
516 }
517 }
518
519 Ok(DagExecutionSummary {
520 models_executed,
521 total_rows_produced,
522 bronze_sources_registered,
523 })
524 }
525}
526
527fn model_assertions(model: &ModelNode) -> (Vec<Assertion>, bool) {
529 let mut fail_on_error = true;
530 let assertions = if let Some(fm) = model.frontmatter.as_ref() {
531 if let Some(tests) = fm.tests.as_ref() {
532 fail_on_error = tests.should_fail_on_error();
533 if tests.is_empty() {
534 Vec::new()
535 } else {
536 let unique = tests
537 .unique
538 .clone()
539 .or_else(|| fm.unique_key.clone())
540 .or_else(|| fm.grain.clone());
541 assertions_from_model_tests(
542 tests.not_null.as_deref(),
543 unique.as_deref(),
544 tests.accepted_values.as_ref(),
545 )
546 }
547 } else if let Some(uk) = fm
548 .unique_key
549 .as_ref()
550 .or(fm.grain.as_ref())
551 .filter(|v| !v.is_empty())
552 {
553 fail_on_error = true;
554 assertions_from_model_tests(None, Some(uk.as_slice()), None)
555 } else {
556 Vec::new()
557 }
558 } else {
559 Vec::new()
560 };
561 (assertions, fail_on_error)
562}
563
564fn log_assertion_result(
565 model: &ModelNode,
566 stats: &StreamWriteStats,
567 fail_on_error: bool,
568) -> Result<()> {
569 if stats.validation.failed_assertions > 0 {
570 let msg = format!(
571 "model '{}' failed {} test(s): {}",
572 model.name,
573 stats.validation.failed_assertions,
574 stats.validation.errors.join("; ")
575 );
576 if fail_on_error {
577 bail!("{msg}");
578 }
579 tracing::warn!("{msg}");
580 } else if stats.validation.passed_assertions > 0 {
581 tracing::info!(
582 "model '{}': {} assertion(s) passed ({} rows)",
583 model.name,
584 stats.validation.passed_assertions,
585 stats.rows
586 );
587 }
588 Ok(())
589}
590
591async fn execute_model_stream(
592 ctx: &SessionContext,
593 model: &ModelNode,
594 dest_path: &Path,
595 write_opts: &MaterializeWriteOptions,
596 assertions: &[Assertion],
597 fail_on_error: bool,
598) -> Result<StreamWriteStats> {
599 let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
600 format!(
601 "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
602 model.name, model.compiled_sql
603 )
604 })?;
605 let stream = df.execute_stream().await.with_context(|| {
606 format!(
607 "E_RBT_SQL: execute_stream failed for model '{}'",
608 model.name
609 )
610 })?;
611
612 let stats = materialize_stream(
613 stream,
614 &model.output_format,
615 dest_path,
616 write_opts,
617 assertions,
618 )
619 .await
620 .with_context(|| {
621 format!(
622 "E_RBT_MATERIALIZE: stream write failed for model '{}' → {}",
623 model.name,
624 dest_path.display()
625 )
626 })?;
627
628 if stats.validation.failed_assertions > 0 {
629 let msg = format!(
630 "model '{}' failed {} test(s): {}",
631 model.name,
632 stats.validation.failed_assertions,
633 stats.validation.errors.join("; ")
634 );
635 if fail_on_error {
636 bail!("{msg}");
637 }
638 tracing::warn!("{msg}");
639 } else if !assertions.is_empty() {
640 tracing::info!(
641 "model '{}': {} assertion(s) passed ({} rows, {} batches, stream)",
642 model.name,
643 stats.validation.passed_assertions,
644 stats.rows,
645 stats.batches
646 );
647 } else {
648 tracing::debug!(
649 model = %model.name,
650 rows = stats.rows,
651 batches = stats.batches,
652 bytes = stats.bytes_written,
653 "stream materialize complete"
654 );
655 }
656 Ok(stats)
657}
658
659async fn execute_model_collect(
660 ctx: &SessionContext,
661 model: &ModelNode,
662 dest_path: &Path,
663 write_opts: &MaterializeWriteOptions,
664 assertions: &[Assertion],
665 fail_on_error: bool,
666) -> Result<usize> {
667 let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
668 format!(
669 "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
670 model.name, model.compiled_sql
671 )
672 })?;
673 let batches = df.collect().await.with_context(|| {
674 format!(
675 "E_RBT_SQL: collect failed for model '{}'",
676 model.name
677 )
678 })?;
679 let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
680
681 MultiFormatWriter::write_batches(&batches, &model.output_format, dest_path)?;
682 if !assertions.is_empty() {
685 let result = RecordBatchValidator::validate_batches(&batches, assertions);
686 if result.failed_assertions > 0 {
687 let msg = format!(
688 "model '{}' failed {} test(s): {}",
689 model.name,
690 result.failed_assertions,
691 result.errors.join("; ")
692 );
693 if fail_on_error {
694 bail!("{msg}");
695 }
696 tracing::warn!("{msg}");
697 } else {
698 tracing::info!(
699 "model '{}': {} assertion(s) passed ({} rows, collect)",
700 model.name,
701 result.passed_assertions,
702 result.total_rows
703 );
704 }
705 }
706
707 let _ = write_opts; Ok(row_count)
709}
710
711fn lake_read_path(format: &OutputFormat, dest_path: &Path) -> PathBuf {
713 match format {
714 OutputFormat::Iceberg => {
715 let hint = dest_path.join(".rbt_iceberg_data");
717 if let Ok(p) = std::fs::read_to_string(&hint) {
718 let p = PathBuf::from(p.trim());
719 if p.exists() {
720 return p;
721 }
722 }
723 let preferred = dest_path.join("data/part-00000.parquet");
724 if preferred.exists() {
725 preferred
726 } else if let Some(p) = find_first_parquet_under(dest_path) {
727 p
728 } else {
729 preferred
730 }
731 }
732 OutputFormat::ParquetAndIceberg => {
733 if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
735 dest_path.to_path_buf()
736 } else {
737 dest_path.with_extension("parquet")
738 }
739 }
740 _ => dest_path.to_path_buf(),
741 }
742}
743
744fn find_first_parquet_under(dir: &Path) -> Option<PathBuf> {
745 let mut stack = vec![dir.to_path_buf()];
746 while let Some(d) = stack.pop() {
747 let entries = std::fs::read_dir(&d).ok()?;
748 for e in entries.flatten() {
749 let p = e.path();
750 if p.is_dir() {
751 stack.push(p);
752 } else if p.extension().and_then(|x| x.to_str()) == Some("parquet") {
753 return Some(p);
754 }
755 }
756 }
757 None
758}
759
760async fn register_model_for_ref(
765 ctx: &SessionContext,
766 name: &str,
767 format: &OutputFormat,
768 dest_path: &Path,
769 backend: RefBackend,
770) -> Result<()> {
771 let _ = ctx.deregister_table(name);
772
773 match backend {
774 RefBackend::MemTable => {
775 let batches = match format {
776 OutputFormat::Parquet
777 | OutputFormat::ZeroCopyClone
778 | OutputFormat::Iceberg
779 | OutputFormat::ParquetAndIceberg => {
780 let path = resolve_lake_read_path(format, dest_path)?;
781 load_parquet_batches(&path).with_context(|| {
782 format!(
783 "E_RBT_REF_MEMTABLE: load {} for ref('{name}')",
784 path.display()
785 )
786 })?
787 }
788 OutputFormat::Jsonl | OutputFormat::Csv => {
789 Box::pin(async {
791 register_model_for_ref(ctx, name, format, dest_path, RefBackend::LakeFile)
793 .await?;
794 let df = ctx.table(name).await.map_err(|e| {
795 anyhow::anyhow!("E_RBT_REF_MEMTABLE: table '{name}': {e}")
796 })?;
797 df.collect().await.map_err(|e| {
798 anyhow::anyhow!("E_RBT_REF_MEMTABLE: collect '{name}': {e}")
799 })
800 })
801 .await?
802 }
803 };
804 if batches.is_empty() {
805 bail!("E_RBT_REF_MEMTABLE: no batches for ref('{name}')");
806 }
807 let _ = ctx.deregister_table(name);
808 let schema = batches[0].schema();
809 let mem_table = MemTable::try_new(schema, vec![batches])
810 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
811 ctx.register_table(name, Arc::new(mem_table))
812 .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
813 }
814 RefBackend::LakeFile => match format {
815 OutputFormat::Parquet
816 | OutputFormat::ZeroCopyClone
817 | OutputFormat::Iceberg
818 | OutputFormat::ParquetAndIceberg => {
819 let path = resolve_lake_read_path(format, dest_path)?;
820 ctx.register_parquet(
821 name,
822 path.to_str().unwrap_or_default(),
823 ParquetReadOptions::default(),
824 )
825 .await
826 .map_err(|e| {
827 anyhow::anyhow!(
828 "E_RBT_REF_REGISTER: register_parquet {} for '{name}': {e}",
829 path.display()
830 )
831 })?;
832 }
833 OutputFormat::Jsonl => {
834 let p = dest_path.to_str().unwrap_or_default();
835 let opts = JsonReadOptions::default()
836 .file_extension(".jsonl")
837 .newline_delimited(true);
838 if let Err(e) = ctx.register_json(name, p, opts).await {
839 tracing::debug!("jsonl register failed ({e}); retry default");
840 ctx.register_json(name, p, JsonReadOptions::default())
841 .await
842 .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_json: {e}"))?;
843 }
844 }
845 OutputFormat::Csv => {
846 ctx.register_csv(
847 name,
848 dest_path.to_str().unwrap_or_default(),
849 CsvReadOptions::default(),
850 )
851 .await
852 .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_csv: {e}"))?;
853 }
854 },
855 }
856 Ok(())
857}
858
859fn resolve_lake_read_path(format: &OutputFormat, dest_path: &Path) -> Result<PathBuf> {
860 let mut path = lake_read_path(format, dest_path);
861 if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
862 let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
863 if alt.exists() {
864 path = alt;
865 }
866 }
867 if !path.exists() {
868 bail!(
869 "E_RBT_REF_MISSING: lake file missing for ref(): expected {}",
870 path.display()
871 );
872 }
873 Ok(path)
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use crate::core::dag::{Materialization, ModelDag, OutputFormat};
880
881 #[tokio::test]
882 async fn test_engine_initialization() -> Result<()> {
883 let engine = TransformationEngine::new();
884 let df = engine.ctx.sql("SELECT 1 AS col").await?;
885 let batches = df.collect().await?;
886 assert_eq!(batches.len(), 1);
887 assert_eq!(batches[0].num_rows(), 1);
888 Ok(())
889 }
890
891 #[tokio::test]
892 async fn test_dag_execution_multi_format() -> Result<()> {
893 let temp_dir = tempfile::tempdir()?;
894 let engine = TransformationEngine::new();
895
896 let mut dag = ModelDag::new();
897 dag.add_model_with_format(
898 "users",
899 "SELECT 1 AS id, 'Alice' AS name",
900 Materialization::Table,
901 OutputFormat::Jsonl,
902 None,
903 "",
904 )?;
905 dag.add_model_with_format(
906 "active_users",
907 "SELECT * FROM {{ ref('users') }} WHERE id = 1",
908 Materialization::Table,
909 OutputFormat::Parquet,
910 None,
911 "",
912 )?;
913 dag.build_graph()?;
914
915 let summary = engine
916 .execute_dag(&dag, temp_dir.path(), temp_dir.path())
917 .await?;
918 assert_eq!(summary.models_executed, 2);
919 assert_eq!(summary.total_rows_produced, 2);
920 assert!(temp_dir.path().join("users.jsonl").exists());
921 assert!(temp_dir.path().join("active_users.parquet").exists());
922 Ok(())
923 }
924
925 #[tokio::test]
926 async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
927 let temp = tempfile::tempdir()?;
928 let bronze_dir = temp.path().join("lake/bronze");
929 std::fs::create_dir_all(&bronze_dir)?;
930 std::fs::write(
931 bronze_dir.join("raw_stock_trades.jsonl"),
932 r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
933{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
934"#,
935 )?;
936
937 let sql = r#"---
938source_format: jsonl
939scan_path: "lake/bronze/raw_stock_trades.jsonl"
940---
941SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
942"#;
943
944 let mut dag = ModelDag::new();
945 dag.add_model_with_format(
946 "stg_stock_trades",
947 sql,
948 Materialization::Table,
949 OutputFormat::Parquet,
950 Some(
951 temp.path()
952 .join("lake/silver/stg_stock_trades.parquet")
953 .to_string_lossy()
954 .into(),
955 ),
956 "",
957 )?;
958 dag.build_graph()?;
959
960 let engine = TransformationEngine::new();
961 let summary = engine
962 .execute_dag(&dag, temp.path(), temp.path().join("out"))
963 .await?;
964 assert_eq!(summary.bronze_sources_registered, 1);
965 assert_eq!(summary.models_executed, 1);
966 assert_eq!(summary.total_rows_produced, 2);
967 assert!(temp
968 .path()
969 .join("lake/silver/stg_stock_trades.parquet")
970 .exists());
971 Ok(())
972 }
973
974 #[tokio::test]
975 async fn test_ref_via_parquet_reread_default() -> Result<()> {
976 use crate::core::project::{MaterializeConfig, RefStrategy};
977
978 let temp = tempfile::tempdir()?;
979 let mut dag = ModelDag::new();
980 dag.add_model_with_format(
981 "stg_a",
982 "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
983 Materialization::Table,
984 OutputFormat::Parquet,
985 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
986 "",
987 )?;
988 dag.add_model_with_format(
989 "tf_b",
990 "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
991 Materialization::Table,
992 OutputFormat::Parquet,
993 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
994 "",
995 )?;
996 dag.build_graph()?;
997
998 let mat = MaterializeConfig {
999 ref_strategy: RefStrategy::Parquet,
1000 memtable_max_rows: 50_000,
1001 ..Default::default()
1002 };
1003 let engine = TransformationEngine::new();
1004 let summary = engine
1005 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1006 .await?;
1007 assert_eq!(summary.models_executed, 2);
1008 assert_eq!(summary.total_rows_produced, 4);
1009 assert!(temp.path().join("tf_b.parquet").exists());
1010 Ok(())
1011 }
1012
1013 #[tokio::test]
1014 async fn test_ref_via_memtable_when_configured() -> Result<()> {
1015 use crate::core::project::{MaterializeConfig, RefStrategy};
1016
1017 let temp = tempfile::tempdir()?;
1018 let mut dag = ModelDag::new();
1019 dag.add_model_with_format(
1020 "stg_a",
1021 "SELECT 1 AS id UNION ALL SELECT 2",
1022 Materialization::Table,
1023 OutputFormat::Parquet,
1024 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1025 "",
1026 )?;
1027 dag.add_model_with_format(
1028 "tf_b",
1029 "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
1030 Materialization::Table,
1031 OutputFormat::Parquet,
1032 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1033 "",
1034 )?;
1035 dag.build_graph()?;
1036
1037 let mat = MaterializeConfig {
1038 ref_strategy: RefStrategy::Memtable,
1039 memtable_max_rows: 50_000,
1040 ..Default::default()
1041 };
1042 let engine = TransformationEngine::new();
1043 let summary = engine
1044 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1045 .await?;
1046 assert_eq!(summary.models_executed, 2);
1047 assert!(temp.path().join("tf_b.parquet").exists());
1048 Ok(())
1049 }
1050
1051 #[tokio::test]
1052 async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
1053 use crate::core::project::{MaterializeConfig, RefStrategy};
1054
1055 let temp = tempfile::tempdir()?;
1057 let mut dag = ModelDag::new();
1058 dag.add_model_with_format(
1059 "stg_a",
1060 "SELECT 1 AS id UNION ALL SELECT 2",
1061 Materialization::Table,
1062 OutputFormat::Parquet,
1063 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1064 "",
1065 )?;
1066 dag.add_model_with_format(
1067 "tf_b",
1068 "SELECT * FROM {{ ref('stg_a') }}",
1069 Materialization::Table,
1070 OutputFormat::Parquet,
1071 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1072 "",
1073 )?;
1074 dag.build_graph()?;
1075
1076 let mat = MaterializeConfig {
1077 ref_strategy: RefStrategy::Memtable,
1078 memtable_max_rows: 1,
1079 ..Default::default()
1080 };
1081 let engine = TransformationEngine::new();
1082 let summary = engine
1083 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1084 .await?;
1085 assert_eq!(summary.models_executed, 2);
1086 assert_eq!(summary.total_rows_produced, 4);
1087 Ok(())
1088 }
1089}