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::core::receipt::{
11 bronze_fingerprint, effective_contract_version, now_unix_ms, ModelRunResult, RunReceipt,
12 RunStatus,
13};
14use crate::core::run_scope::RunScope;
15use crate::materializer::{
16 incremental_ref_path, load_parquet_batches, materialize_incremental_append_stream,
17 materialize_stream, new_wap_run_id, sibling_iceberg_dir, stamp_batch, wap_publish,
18 LineageStamp, MaterializeWriteOptions, MultiFormatWriter, StreamWriteStats, WapModelPaths,
19};
20use crate::engine::udf::register_builtin_udfs;
21use crate::testing::{assertions_from_model_tests, Assertion, RecordBatchValidator};
22use anyhow::{bail, Context, Result};
23use datafusion::datasource::MemTable;
24use datafusion::execution::context::SessionContext;
25use datafusion::physical_plan::SendableRecordBatchStream;
26use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
27use iceberg::Catalog;
28use iceberg_datafusion::IcebergCatalogProvider;
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, Mutex};
32
33pub use bronze::{
34 register_bronze_for_model, register_bronze_for_model_scoped, register_bronze_sources_for_dag,
35 register_bronze_sources_for_dag_scoped, BronzeRegistrationMode, BronzeSourceMeta,
36 BronzeTableProvider,
37};
38
39#[derive(Debug, Clone)]
41pub struct DagExecutionSummary {
42 pub models_executed: usize,
43 pub total_rows_produced: usize,
44 pub bronze_sources_registered: usize,
45 pub skipped: bool,
47 pub skip_reason: Option<String>,
48 pub bronze_fingerprint: Option<String>,
49 pub run_id: Option<String>,
50 pub receipt_path: Option<PathBuf>,
51 pub model_results: Vec<ModelRunResult>,
52}
53
54impl Default for DagExecutionSummary {
55 fn default() -> Self {
56 Self {
57 models_executed: 0,
58 total_rows_produced: 0,
59 bronze_sources_registered: 0,
60 skipped: false,
61 skip_reason: None,
62 bronze_fingerprint: None,
63 run_id: None,
64 receipt_path: None,
65 model_results: Vec::new(),
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct PreviewResult {
73 pub model: String,
74 pub compiled_sql: String,
75 pub limit: usize,
76 pub rows: usize,
77 pub batches: Vec<arrow::record_batch::RecordBatch>,
78 pub ancestors_executed: usize,
79}
80
81#[derive(Default)]
83pub struct RbtEngineBuilder {
84 catalogs: Vec<(String, Arc<dyn Catalog>)>,
85}
86
87impl RbtEngineBuilder {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
93 self.catalogs.push((name.into(), catalog));
94 self
95 }
96
97 pub async fn build(self) -> Result<TransformationEngine> {
98 let engine = TransformationEngine::new();
99 for (name, cat) in self.catalogs {
100 engine.register_iceberg_catalog(&name, cat).await?;
101 }
102 Ok(engine)
103 }
104}
105
106pub struct TransformationEngine {
107 pub ctx: SessionContext,
108 project_cache: Mutex<Option<(PathBuf, Arc<RbtProjectConfig>)>>,
112}
113
114impl Default for TransformationEngine {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl TransformationEngine {
121 pub fn new() -> Self {
122 let ctx = SessionContext::new();
123 if let Err(e) = register_builtin_udfs(&ctx) {
124 tracing::warn!("E_RBT_UDF: failed to register builtins: {e}");
125 }
126 Self {
127 ctx,
128 project_cache: Mutex::new(None),
129 }
130 }
131
132 pub fn load_project_config(&self, project_dir: &Path) -> Result<Arc<RbtProjectConfig>> {
134 let key = project_dir
135 .canonicalize()
136 .unwrap_or_else(|_| project_dir.to_path_buf());
137 let mut guard = self
138 .project_cache
139 .lock()
140 .map_err(|_| anyhow::anyhow!("E_RBT_ENGINE: project config cache lock poisoned"))?;
141 if let Some((ref cached_dir, ref cfg)) = *guard {
142 if *cached_dir == key {
143 return Ok(Arc::clone(cfg));
144 }
145 }
146 let cfg = Arc::new(RbtProjectConfig::load(project_dir).with_context(|| {
147 format!(
148 "E_RBT_PROJECT_LOAD: failed loading rbt_project.yml under {}",
149 project_dir.display()
150 )
151 })?);
152 *guard = Some((key, Arc::clone(&cfg)));
153 Ok(cfg)
154 }
155
156 pub fn clear_project_cache(&self) {
158 if let Ok(mut guard) = self.project_cache.lock() {
159 *guard = None;
160 }
161 }
162
163 pub async fn register_iceberg_catalog(
165 &self,
166 catalog_name: &str,
167 catalog: Arc<dyn Catalog>,
168 ) -> Result<()> {
169 tracing::info!(
170 "Registering Iceberg catalog '{}' into DataFusion SessionContext",
171 catalog_name
172 );
173 let provider = IcebergCatalogProvider::try_new(catalog).await?;
174 self.ctx.register_catalog(catalog_name, Arc::new(provider));
175 Ok(())
176 }
177
178 pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
180 tracing::info!(
181 "Executing SQL transform via Apache DataFusion engine: {}",
182 sql
183 );
184 let df = self.ctx.sql(sql).await?;
185 let stream = df.execute_stream().await?;
186 Ok(stream)
187 }
188
189 pub async fn execute_dag(
197 &self,
198 dag: &ModelDag,
199 project_dir: impl AsRef<Path>,
200 output_dir: impl AsRef<Path>,
201 ) -> Result<DagExecutionSummary> {
202 let project_dir = project_dir.as_ref();
203 let config = self.load_project_config(project_dir)?;
204 self.execute_dag_with_config(dag, project_dir, output_dir, &config)
205 .await
206 }
207
208 pub async fn execute_dag_with_materialize(
210 &self,
211 dag: &ModelDag,
212 project_dir: impl AsRef<Path>,
213 output_dir: impl AsRef<Path>,
214 materialize: &MaterializeConfig,
215 ) -> Result<DagExecutionSummary> {
216 let project_dir = project_dir.as_ref();
217 let mut config = (*self.load_project_config(project_dir)?).clone();
218 config.materialize = materialize.clone();
219 self.execute_dag_with_config(dag, project_dir, output_dir, &config)
220 .await
221 }
222
223 pub async fn preview_model(
228 &self,
229 full_dag: &ModelDag,
230 project_dir: impl AsRef<Path>,
231 output_dir: impl AsRef<Path>,
232 model_name: &str,
233 limit: usize,
234 ) -> Result<PreviewResult> {
235 let project_dir = project_dir.as_ref();
236 let config = self.load_project_config(project_dir)?;
237 let limit = limit.clamp(1, 10_000);
238
239 let sub = full_dag
240 .apply_select(Some(model_name), crate::core::SelectMode::Execute)
241 .with_context(|| {
242 format!(
243 "E_RBT_PREVIEW: cannot select model '{model_name}' (check name / --select)"
244 )
245 })?;
246 let seq = sub.topological_sequence()?;
247 let target = seq
248 .iter()
249 .find(|m| m.name == model_name)
250 .cloned()
251 .ok_or_else(|| {
252 anyhow::anyhow!("E_RBT_PREVIEW: model '{model_name}' not found in project DAG")
253 })?;
254 self.preview_model_inner(full_dag, project_dir, output_dir, &target, limit, &config)
255 .await
256 }
257
258 async fn preview_model_inner(
259 &self,
260 full_dag: &ModelDag,
261 project_dir: &Path,
262 output_dir: impl AsRef<Path>,
263 target: &ModelNode,
264 limit: usize,
265 config: &RbtProjectConfig,
266 ) -> Result<PreviewResult> {
267 let output_dir = output_dir.as_ref();
268 let sub = full_dag
269 .apply_select(Some(&target.name), crate::core::SelectMode::Execute)?;
270 let seq = sub.topological_sequence()?;
271 let ancestor_names: Vec<String> = seq
272 .iter()
273 .map(|m| m.name.clone())
274 .filter(|n| n != &target.name)
275 .collect();
276
277 let mut ancestors_executed = 0usize;
278 if !ancestor_names.is_empty() {
279 let anc_select = ancestor_names.join(",");
280 let anc_dag = full_dag
281 .apply_select(Some(&anc_select), crate::core::SelectMode::Execute)
282 .context("E_RBT_PREVIEW: ancestor select failed")?;
283 let summary = self
284 .execute_dag_with_config(&anc_dag, project_dir, output_dir, config)
285 .await
286 .context("E_RBT_PREVIEW: ancestor materialize failed")?;
287 ancestors_executed = summary.models_executed;
288 } else {
289 let mut registered = HashSet::new();
291 register_bronze_sources_for_dag(
292 &self.ctx,
293 &sub,
294 project_dir,
295 &mut registered,
296 config,
297 )
298 .await
299 .context("E_RBT_PREVIEW: bronze registration failed")?;
300 }
301
302 let mut registered = HashSet::new();
304 register_bronze_for_model(&self.ctx, target, project_dir, &mut registered, config)
305 .await?;
306
307 let preview_sql = format!(
308 "SELECT * FROM (\n{}\n) AS _rbt_preview LIMIT {}",
309 target.compiled_sql.trim().trim_end_matches(';'),
310 limit
311 );
312 let df = self.ctx.sql(&preview_sql).await.with_context(|| {
313 format!(
314 "E_RBT_PREVIEW: SQL failed for model '{}': {preview_sql}",
315 target.name
316 )
317 })?;
318 let batches = df.collect().await.with_context(|| {
319 format!("E_RBT_PREVIEW: collect failed for model '{}'", target.name)
320 })?;
321 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
322
323 Ok(PreviewResult {
324 model: target.name.clone(),
325 compiled_sql: target.compiled_sql.clone(),
326 limit,
327 rows,
328 batches,
329 ancestors_executed,
330 })
331 }
332
333 pub async fn execute_dag_with_config(
335 &self,
336 dag: &ModelDag,
337 project_dir: impl AsRef<Path>,
338 output_dir: impl AsRef<Path>,
339 config: &RbtProjectConfig,
340 ) -> Result<DagExecutionSummary> {
341 self.execute_dag_with_scope(dag, project_dir, output_dir, config, &RunScope::default())
342 .await
343 }
344
345 pub async fn execute_dag_with_scope(
347 &self,
348 dag: &ModelDag,
349 project_dir: impl AsRef<Path>,
350 output_dir: impl AsRef<Path>,
351 config: &RbtProjectConfig,
352 scope: &RunScope,
353 ) -> Result<DagExecutionSummary> {
354 let project_dir = project_dir.as_ref();
355 let output_base = output_dir.as_ref();
356 let materialize = &config.materialize;
357 tokio::fs::create_dir_all(output_base).await?;
358
359 let started = now_unix_ms();
360 let run_id = scope.resolve_run_id();
361 let scope_key = scope.scope_key();
362 let contract = effective_contract_version(config, scope);
363 let fp = bronze_fingerprint(dag, project_dir, config, scope)
364 .context("E_RBT_FINGERPRINT: bronze fingerprint failed")?;
365
366 if scope.skip_if_fingerprint_match {
367 if let Some(prev) = RunReceipt::load_latest_for_scope(project_dir, &scope_key) {
368 if prev.status == RunStatus::Ok
369 && prev.bronze_fingerprint == fp
370 && prev.contract_version == contract
371 {
372 let finished = now_unix_ms();
373 let mut summary = DagExecutionSummary {
374 models_executed: 0,
375 total_rows_produced: 0,
376 bronze_sources_registered: 0,
377 skipped: true,
378 skip_reason: Some(
379 "bronze fingerprint + contract_version match previous successful receipt"
380 .into(),
381 ),
382 bronze_fingerprint: Some(fp.clone()),
383 run_id: Some(run_id.clone()),
384 receipt_path: None,
385 model_results: Vec::new(),
386 };
387 if scope.write_receipt {
388 let receipt = RunReceipt {
389 schema_version: RunReceipt::SCHEMA_VERSION,
390 run_id: run_id.clone(),
391 project: config.name.clone(),
392 package_version: crate::VERSION.into(),
393 contract_version: contract.clone(),
394 scope_key: scope_key.clone(),
395 vars: scope.vars.clone(),
396 status: RunStatus::Skipped,
397 skipped: true,
398 skip_reason: summary.skip_reason.clone(),
399 bronze_fingerprint: fp.clone(),
400 models_executed: 0,
401 total_rows: 0,
402 bronze_sources: 0,
403 model_results: Vec::new(),
404 started_unix_ms: started,
405 finished_unix_ms: finished,
406 wall_ms: finished.saturating_sub(started),
407 error: None,
408 };
409 summary.receipt_path = Some(receipt.write(project_dir)?);
410 }
411 tracing::info!(
412 "E_RBT_SKIP: identical bronze fingerprint for scope {scope_key}; skipping materialize"
413 );
414 return Ok(summary);
415 }
416 }
417 }
418
419 let mut registered = HashSet::new();
420 let bronze_sources_registered = register_bronze_sources_for_dag_scoped(
421 &self.ctx,
422 dag,
423 project_dir,
424 &mut registered,
425 config,
426 Some(scope),
427 )
428 .await
429 .context("frontmatter-driven bronze registration failed")?;
430
431 let tiers = dag.execution_tiers()?;
432 let mut models_executed = 0;
433 let mut total_rows_produced = 0;
434 let mut model_results: Vec<ModelRunResult> = Vec::new();
435 let wap_run_id = if materialize.wap {
436 Some(new_wap_run_id())
437 } else {
438 None
439 };
440
441 for (tier_idx, tier) in tiers.iter().enumerate() {
442 tracing::info!(
443 "Executing DAG Tier {} with {} parallel models",
444 tier_idx,
445 tier.len()
446 );
447
448 for model in tier {
449 tracing::info!("Executing model '{}'...", model.name);
450
451 register_bronze_for_model_scoped(
453 &self.ctx,
454 model,
455 project_dir,
456 &mut registered,
457 config,
458 Some(scope),
459 )
460 .await?;
461
462 let dest_path = model
463 .output_path
464 .as_ref()
465 .map(PathBuf::from)
466 .unwrap_or_else(|| match model.output_format {
467 OutputFormat::Iceberg => output_base.join(&model.name),
468 OutputFormat::Jsonl => output_base.join(format!("{}.jsonl", model.name)),
469 OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
470 _ => output_base.join(format!("{}.parquet", model.name)),
471 });
472
473 if let Some(parent) = dest_path.parent() {
474 std::fs::create_dir_all(parent)?;
475 }
476
477 let (assertions, fail_on_error) = model_assertions(model);
478 let mut write_opts =
479 MaterializeWriteOptions::from_config(materialize, fail_on_error);
480 if model
481 .frontmatter
482 .as_ref()
483 .map(|f| f.wants_lineage_stamp())
484 .unwrap_or(false)
485 {
486 write_opts = write_opts.with_lineage(LineageStamp {
487 run_id: run_id.clone(),
488 contract_version: contract.clone(),
489 model: model.name.clone(),
490 bronze_fingerprint: Some(fp.clone()),
491 });
492 }
493 let mode = materialize.effective_mode();
494
495 let (write_path, wap_paths) = if let Some(ref run_id) = wap_run_id {
497 if matches!(
498 model.output_format,
499 OutputFormat::Parquet | OutputFormat::ZeroCopyClone
500 ) && model.materialization != Materialization::IncrementalAppend
501 {
502 let paths =
503 WapModelPaths::for_model(project_dir, run_id, &model.name, &dest_path);
504 if let Some(p) = paths.stage_path.parent() {
505 std::fs::create_dir_all(p)?;
506 }
507 (paths.stage_path.clone(), Some(paths))
508 } else {
509 (dest_path.clone(), None)
510 }
511 } else {
512 (dest_path.clone(), None)
513 };
514
515 let (row_count, write_stats) = match (
516 &model.materialization,
517 &model.output_format,
518 mode,
519 ) {
520 (
521 Materialization::IncrementalAppend,
522 OutputFormat::Parquet | OutputFormat::ZeroCopyClone,
523 MaterializeMode::Stream,
524 ) => {
525 let df = self.ctx.sql(&model.compiled_sql).await.with_context(|| {
526 format!("E_RBT_SQL: model '{}'", model.name)
527 })?;
528 let stream = df.execute_stream().await?;
529 let stats = materialize_incremental_append_stream(
530 stream,
531 &dest_path,
532 &write_opts,
533 &assertions,
534 )
535 .await
536 .with_context(|| {
537 format!(
538 "E_RBT_INCREMENTAL: model '{}' append failed",
539 model.name
540 )
541 })?;
542 log_assertion_result(model, &stats, fail_on_error)?;
543 (stats.rows, Some(stats))
544 }
545 (
546 Materialization::IncrementalMerge,
547 _,
548 _,
549 ) => {
550 bail!(
551 "E_RBT_INCREMENTAL: model '{}': incremental_merge is not implemented yet \
552 (use incremental_append for part-file appends)",
553 model.name
554 );
555 }
556 (_, _, MaterializeMode::Stream) => {
557 let stats = execute_model_stream(
558 &self.ctx,
559 model,
560 &write_path,
561 &write_opts,
562 &assertions,
563 fail_on_error,
564 )
565 .await?;
566 (stats.rows, Some(stats))
567 }
568 (_, _, MaterializeMode::Collect) => {
569 let rows = execute_model_collect(
570 &self.ctx,
571 model,
572 &write_path,
573 &write_opts,
574 &assertions,
575 fail_on_error,
576 )
577 .await?;
578 (rows, None)
579 }
580 };
581
582 if let Some(ref paths) = wap_paths {
584 let validation = write_stats
585 .as_ref()
586 .map(|s| s.validation.clone())
587 .unwrap_or_else(|| crate::testing::ValidationResult {
588 total_rows: row_count,
589 passed_assertions: 0,
590 failed_assertions: 0,
591 errors: Vec::new(),
592 });
593 wap_publish(paths, &model.name, row_count, &validation)?;
594 }
595
596 if row_count > 0
598 || matches!(
599 model.output_format,
600 OutputFormat::Parquet
601 | OutputFormat::Iceberg
602 | OutputFormat::ParquetAndIceberg
603 | OutputFormat::ZeroCopyClone
604 )
605 {
606 let backend = materialize.choose_ref_backend(row_count);
607 if row_count > 0 {
608 let ref_path = if model.materialization == Materialization::IncrementalAppend
609 && matches!(
610 model.output_format,
611 OutputFormat::Parquet | OutputFormat::ZeroCopyClone
612 ) {
613 incremental_ref_path(&dest_path)
614 } else {
615 dest_path.clone()
616 };
617 register_model_for_ref(
618 &self.ctx,
619 &model.name,
620 &model.output_format,
621 &ref_path,
622 backend,
623 )
624 .await
625 .with_context(|| {
626 format!(
627 "E_RBT_REF_REGISTER: model '{}' (backend={:?}, rows={}, mode={:?})",
628 model.name, backend, row_count, mode
629 )
630 })?;
631 tracing::debug!(
632 model = %model.name,
633 rows = row_count,
634 ?backend,
635 ?mode,
636 strategy = ?materialize.ref_strategy,
637 mat = ?model.materialization,
638 "registered model for ref()"
639 );
640 }
641 }
642
643 if let Some(fm) = model.frontmatter.as_ref() {
645 if let Some(tests) = fm.tests.as_ref() {
646 if let Some(rels) = tests.relationships.as_ref() {
647 if !rels.is_empty() {
648 check_relationships(
649 &self.ctx,
650 &model.name,
651 rels,
652 tests.should_fail_on_error(),
653 )
654 .await?;
655 }
656 }
657 }
658 }
659
660 models_executed += 1;
661 total_rows_produced += row_count;
662 model_results.push(ModelRunResult {
663 name: model.name.clone(),
664 rows: row_count,
665 output_path: Some(dest_path.display().to_string()),
666 });
667 }
668 }
669
670 let finished = now_unix_ms();
671 let mut summary = DagExecutionSummary {
672 models_executed,
673 total_rows_produced,
674 bronze_sources_registered,
675 skipped: false,
676 skip_reason: None,
677 bronze_fingerprint: Some(fp.clone()),
678 run_id: Some(run_id.clone()),
679 receipt_path: None,
680 model_results: model_results.clone(),
681 };
682
683 if scope.write_receipt {
684 let receipt = RunReceipt {
685 schema_version: RunReceipt::SCHEMA_VERSION,
686 run_id: run_id.clone(),
687 project: config.name.clone(),
688 package_version: crate::VERSION.into(),
689 contract_version: contract,
690 scope_key,
691 vars: scope.vars.clone(),
692 status: RunStatus::Ok,
693 skipped: false,
694 skip_reason: None,
695 bronze_fingerprint: fp,
696 models_executed,
697 total_rows: total_rows_produced,
698 bronze_sources: bronze_sources_registered,
699 model_results,
700 started_unix_ms: started,
701 finished_unix_ms: finished,
702 wall_ms: finished.saturating_sub(started),
703 error: None,
704 };
705 summary.receipt_path = Some(receipt.write(project_dir)?);
706 }
707
708 Ok(summary)
709 }
710}
711
712fn model_assertions(model: &ModelNode) -> (Vec<Assertion>, bool) {
714 let mut fail_on_error = true;
715 let assertions = if let Some(fm) = model.frontmatter.as_ref() {
716 if let Some(tests) = fm.tests.as_ref() {
717 fail_on_error = tests.should_fail_on_error();
718 if tests.is_empty() {
719 Vec::new()
720 } else {
721 let unique = tests
722 .unique
723 .clone()
724 .or_else(|| fm.unique_key.clone())
725 .or_else(|| fm.grain.clone());
726 assertions_from_model_tests(
727 tests.not_null.as_deref(),
728 unique.as_deref(),
729 tests.accepted_values.as_ref(),
730 )
731 }
732 } else if let Some(uk) = fm
733 .unique_key
734 .as_ref()
735 .or(fm.grain.as_ref())
736 .filter(|v| !v.is_empty())
737 {
738 fail_on_error = true;
739 assertions_from_model_tests(None, Some(uk.as_slice()), None)
740 } else {
741 Vec::new()
742 }
743 } else {
744 Vec::new()
745 };
746 (assertions, fail_on_error)
747}
748
749fn log_assertion_result(
750 model: &ModelNode,
751 stats: &StreamWriteStats,
752 fail_on_error: bool,
753) -> Result<()> {
754 if stats.validation.failed_assertions > 0 {
755 let msg = format!(
756 "model '{}' failed {} test(s): {}",
757 model.name,
758 stats.validation.failed_assertions,
759 stats.validation.errors.join("; ")
760 );
761 if fail_on_error {
762 bail!("{msg}");
763 }
764 tracing::warn!("{msg}");
765 } else if stats.validation.passed_assertions > 0 {
766 tracing::info!(
767 "model '{}': {} assertion(s) passed ({} rows)",
768 model.name,
769 stats.validation.passed_assertions,
770 stats.rows
771 );
772 }
773 Ok(())
774}
775
776async fn execute_model_stream(
777 ctx: &SessionContext,
778 model: &ModelNode,
779 dest_path: &Path,
780 write_opts: &MaterializeWriteOptions,
781 assertions: &[Assertion],
782 fail_on_error: bool,
783) -> Result<StreamWriteStats> {
784 let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
785 format!(
786 "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
787 model.name, model.compiled_sql
788 )
789 })?;
790 let stream = df.execute_stream().await.with_context(|| {
791 format!(
792 "E_RBT_SQL: execute_stream failed for model '{}'",
793 model.name
794 )
795 })?;
796
797 let stats = materialize_stream(
798 stream,
799 &model.output_format,
800 dest_path,
801 write_opts,
802 assertions,
803 )
804 .await
805 .with_context(|| {
806 format!(
807 "E_RBT_MATERIALIZE: stream write failed for model '{}' → {}",
808 model.name,
809 dest_path.display()
810 )
811 })?;
812
813 if stats.validation.failed_assertions > 0 {
814 let msg = format!(
815 "model '{}' failed {} test(s): {}",
816 model.name,
817 stats.validation.failed_assertions,
818 stats.validation.errors.join("; ")
819 );
820 if fail_on_error {
821 bail!("{msg}");
822 }
823 tracing::warn!("{msg}");
824 } else if !assertions.is_empty() {
825 tracing::info!(
826 "model '{}': {} assertion(s) passed ({} rows, {} batches, stream)",
827 model.name,
828 stats.validation.passed_assertions,
829 stats.rows,
830 stats.batches
831 );
832 } else {
833 tracing::debug!(
834 model = %model.name,
835 rows = stats.rows,
836 batches = stats.batches,
837 bytes = stats.bytes_written,
838 "stream materialize complete"
839 );
840 }
841 Ok(stats)
842}
843
844async fn execute_model_collect(
845 ctx: &SessionContext,
846 model: &ModelNode,
847 dest_path: &Path,
848 write_opts: &MaterializeWriteOptions,
849 assertions: &[Assertion],
850 fail_on_error: bool,
851) -> Result<usize> {
852 let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
853 format!(
854 "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
855 model.name, model.compiled_sql
856 )
857 })?;
858 let mut batches = df.collect().await.with_context(|| {
859 format!(
860 "E_RBT_SQL: collect failed for model '{}'",
861 model.name
862 )
863 })?;
864 if let Some(ref lin) = write_opts.lineage {
865 batches = batches
866 .iter()
867 .map(|b| stamp_batch(b, lin))
868 .collect::<Result<Vec<_>>>()?;
869 }
870 let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
871
872 MultiFormatWriter::write_batches(&batches, &model.output_format, dest_path)?;
873 if !assertions.is_empty() {
876 let result = RecordBatchValidator::validate_batches(&batches, assertions);
877 if result.failed_assertions > 0 {
878 let msg = format!(
879 "model '{}' failed {} test(s): {}",
880 model.name,
881 result.failed_assertions,
882 result.errors.join("; ")
883 );
884 if fail_on_error {
885 bail!("{msg}");
886 }
887 tracing::warn!("{msg}");
888 } else {
889 tracing::info!(
890 "model '{}': {} assertion(s) passed ({} rows, collect)",
891 model.name,
892 result.passed_assertions,
893 result.total_rows
894 );
895 }
896 }
897
898 Ok(row_count)
899}
900
901async fn check_relationships(
903 ctx: &SessionContext,
904 child_model: &str,
905 rels: &[crate::core::frontmatter::RelationshipTest],
906 fail_on_error: bool,
907) -> Result<()> {
908 for rel in rels {
909 let parent_col = rel.parent_column();
910 let sql = format!(
912 r#"SELECT COUNT(*) AS orphan_cnt FROM "{child}" c
913WHERE c."{child_col}" IS NOT NULL
914 AND NOT EXISTS (
915 SELECT 1 FROM "{parent}" p WHERE p."{parent_col}" = c."{child_col}"
916 )"#,
917 child = child_model.replace('"', ""),
918 child_col = rel.column.replace('"', ""),
919 parent = rel.to_model.replace('"', ""),
920 parent_col = parent_col.replace('"', ""),
921 );
922 let df = ctx.sql(&sql).await.with_context(|| {
923 format!(
924 "E_RBT_RELATIONSHIP: SQL failed for {}.{} → {}.{}: {sql}",
925 child_model, rel.column, rel.to_model, parent_col
926 )
927 })?;
928 let batches = df.collect().await?;
929 use arrow::array::Array;
930 let orphans = batches
931 .first()
932 .map(|b| {
933 let col = b.column(0);
934 if col.len() == 0 || col.is_null(0) {
935 return 0i64;
936 }
937 if let Some(a) = col.as_any().downcast_ref::<arrow::array::Int64Array>() {
938 return a.value(0);
939 }
940 if let Some(a) = col.as_any().downcast_ref::<arrow::array::UInt64Array>() {
941 return a.value(0) as i64;
942 }
943 if let Some(a) = col.as_any().downcast_ref::<arrow::array::Int32Array>() {
944 return i64::from(a.value(0));
945 }
946 0
947 })
948 .unwrap_or(0);
949 if orphans > 0 {
950 let msg = format!(
951 "E_RBT_RELATIONSHIP: model '{child_model}' column '{}' has {orphans} value(s) \
952 not found in {}.{}",
953 rel.column, rel.to_model, parent_col
954 );
955 if fail_on_error {
956 bail!("{msg}");
957 }
958 tracing::warn!("{msg}");
959 } else {
960 tracing::info!(
961 "model '{}': relationship {} → {}.{} ok",
962 child_model,
963 rel.column,
964 rel.to_model,
965 parent_col
966 );
967 }
968 }
969 Ok(())
970}
971
972fn lake_read_path(format: &OutputFormat, dest_path: &Path) -> PathBuf {
974 match format {
975 OutputFormat::Iceberg => {
976 let hint = dest_path.join(".rbt_iceberg_data");
978 if let Ok(p) = std::fs::read_to_string(&hint) {
979 let p = PathBuf::from(p.trim());
980 if p.exists() {
981 return p;
982 }
983 }
984 let preferred = dest_path.join("data/part-00000.parquet");
985 if preferred.exists() {
986 preferred
987 } else if let Some(p) = find_first_parquet_under(dest_path) {
988 p
989 } else {
990 preferred
991 }
992 }
993 OutputFormat::ParquetAndIceberg => {
994 if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
996 dest_path.to_path_buf()
997 } else {
998 dest_path.with_extension("parquet")
999 }
1000 }
1001 _ => dest_path.to_path_buf(),
1002 }
1003}
1004
1005fn find_first_parquet_under(dir: &Path) -> Option<PathBuf> {
1006 let mut stack = vec![dir.to_path_buf()];
1007 while let Some(d) = stack.pop() {
1008 let entries = std::fs::read_dir(&d).ok()?;
1009 for e in entries.flatten() {
1010 let p = e.path();
1011 if p.is_dir() {
1012 stack.push(p);
1013 } else if p.extension().and_then(|x| x.to_str()) == Some("parquet") {
1014 return Some(p);
1015 }
1016 }
1017 }
1018 None
1019}
1020
1021async fn register_model_for_ref(
1026 ctx: &SessionContext,
1027 name: &str,
1028 format: &OutputFormat,
1029 dest_path: &Path,
1030 backend: RefBackend,
1031) -> Result<()> {
1032 let _ = ctx.deregister_table(name);
1033
1034 match backend {
1035 RefBackend::MemTable => {
1036 let batches = match format {
1037 OutputFormat::Parquet
1038 | OutputFormat::ZeroCopyClone
1039 | OutputFormat::Iceberg
1040 | OutputFormat::ParquetAndIceberg => {
1041 let path = resolve_lake_read_path(format, dest_path)?;
1042 load_parquet_batches(&path).with_context(|| {
1043 format!(
1044 "E_RBT_REF_MEMTABLE: load {} for ref('{name}')",
1045 path.display()
1046 )
1047 })?
1048 }
1049 OutputFormat::Jsonl | OutputFormat::Csv => {
1050 Box::pin(async {
1052 register_model_for_ref(ctx, name, format, dest_path, RefBackend::LakeFile)
1054 .await?;
1055 let df = ctx.table(name).await.map_err(|e| {
1056 anyhow::anyhow!("E_RBT_REF_MEMTABLE: table '{name}': {e}")
1057 })?;
1058 df.collect().await.map_err(|e| {
1059 anyhow::anyhow!("E_RBT_REF_MEMTABLE: collect '{name}': {e}")
1060 })
1061 })
1062 .await?
1063 }
1064 };
1065 if batches.is_empty() {
1066 bail!("E_RBT_REF_MEMTABLE: no batches for ref('{name}')");
1067 }
1068 let _ = ctx.deregister_table(name);
1069 let schema = batches[0].schema();
1070 let mem_table = MemTable::try_new(schema, vec![batches])
1071 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
1072 ctx.register_table(name, Arc::new(mem_table))
1073 .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
1074 }
1075 RefBackend::LakeFile => match format {
1076 OutputFormat::Parquet
1077 | OutputFormat::ZeroCopyClone
1078 | OutputFormat::Iceberg
1079 | OutputFormat::ParquetAndIceberg => {
1080 let path = resolve_lake_read_path(format, dest_path)?;
1081 ctx.register_parquet(
1082 name,
1083 path.to_str().unwrap_or_default(),
1084 ParquetReadOptions::default(),
1085 )
1086 .await
1087 .map_err(|e| {
1088 anyhow::anyhow!(
1089 "E_RBT_REF_REGISTER: register_parquet {} for '{name}': {e}",
1090 path.display()
1091 )
1092 })?;
1093 }
1094 OutputFormat::Jsonl => {
1095 let p = dest_path.to_str().unwrap_or_default();
1096 let opts = JsonReadOptions::default()
1097 .file_extension(".jsonl")
1098 .newline_delimited(true);
1099 if let Err(e) = ctx.register_json(name, p, opts).await {
1100 tracing::debug!("jsonl register failed ({e}); retry default");
1101 ctx.register_json(name, p, JsonReadOptions::default())
1102 .await
1103 .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_json: {e}"))?;
1104 }
1105 }
1106 OutputFormat::Csv => {
1107 ctx.register_csv(
1108 name,
1109 dest_path.to_str().unwrap_or_default(),
1110 CsvReadOptions::default(),
1111 )
1112 .await
1113 .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_csv: {e}"))?;
1114 }
1115 },
1116 }
1117 Ok(())
1118}
1119
1120fn resolve_lake_read_path(format: &OutputFormat, dest_path: &Path) -> Result<PathBuf> {
1121 let mut path = lake_read_path(format, dest_path);
1122 if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
1123 let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
1124 if alt.exists() {
1125 path = alt;
1126 }
1127 }
1128 if !path.exists() {
1129 bail!(
1130 "E_RBT_REF_MISSING: lake file missing for ref(): expected {}",
1131 path.display()
1132 );
1133 }
1134 Ok(path)
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::core::dag::{Materialization, ModelDag, OutputFormat};
1141
1142 #[tokio::test]
1143 async fn test_engine_initialization() -> Result<()> {
1144 let engine = TransformationEngine::new();
1145 let df = engine.ctx.sql("SELECT 1 AS col").await?;
1146 let batches = df.collect().await?;
1147 assert_eq!(batches.len(), 1);
1148 assert_eq!(batches[0].num_rows(), 1);
1149 Ok(())
1150 }
1151
1152 #[tokio::test]
1153 async fn test_dag_execution_multi_format() -> Result<()> {
1154 let temp_dir = tempfile::tempdir()?;
1155 let engine = TransformationEngine::new();
1156
1157 let mut dag = ModelDag::new();
1158 dag.add_model_with_format(
1159 "users",
1160 "SELECT 1 AS id, 'Alice' AS name",
1161 Materialization::Table,
1162 OutputFormat::Jsonl,
1163 None,
1164 "",
1165 )?;
1166 dag.add_model_with_format(
1167 "active_users",
1168 "SELECT * FROM {{ ref('users') }} WHERE id = 1",
1169 Materialization::Table,
1170 OutputFormat::Parquet,
1171 None,
1172 "",
1173 )?;
1174 dag.build_graph()?;
1175
1176 let summary = engine
1177 .execute_dag(&dag, temp_dir.path(), temp_dir.path())
1178 .await?;
1179 assert_eq!(summary.models_executed, 2);
1180 assert_eq!(summary.total_rows_produced, 2);
1181 assert!(temp_dir.path().join("users.jsonl").exists());
1182 assert!(temp_dir.path().join("active_users.parquet").exists());
1183 Ok(())
1184 }
1185
1186 #[tokio::test]
1187 async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
1188 let temp = tempfile::tempdir()?;
1189 let bronze_dir = temp.path().join("lake/bronze");
1190 std::fs::create_dir_all(&bronze_dir)?;
1191 std::fs::write(
1192 bronze_dir.join("raw_stock_trades.jsonl"),
1193 r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
1194{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
1195"#,
1196 )?;
1197
1198 let sql = r#"---
1199source_format: jsonl
1200scan_path: "lake/bronze/raw_stock_trades.jsonl"
1201---
1202SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
1203"#;
1204
1205 let mut dag = ModelDag::new();
1206 dag.add_model_with_format(
1207 "stg_stock_trades",
1208 sql,
1209 Materialization::Table,
1210 OutputFormat::Parquet,
1211 Some(
1212 temp.path()
1213 .join("lake/silver/stg_stock_trades.parquet")
1214 .to_string_lossy()
1215 .into(),
1216 ),
1217 "",
1218 )?;
1219 dag.build_graph()?;
1220
1221 let engine = TransformationEngine::new();
1222 let summary = engine
1223 .execute_dag(&dag, temp.path(), temp.path().join("out"))
1224 .await?;
1225 assert_eq!(summary.bronze_sources_registered, 1);
1226 assert_eq!(summary.models_executed, 1);
1227 assert_eq!(summary.total_rows_produced, 2);
1228 assert!(temp
1229 .path()
1230 .join("lake/silver/stg_stock_trades.parquet")
1231 .exists());
1232 Ok(())
1233 }
1234
1235 #[tokio::test]
1236 async fn test_ref_via_parquet_reread_default() -> Result<()> {
1237 use crate::core::project::{MaterializeConfig, RefStrategy};
1238
1239 let temp = tempfile::tempdir()?;
1240 let mut dag = ModelDag::new();
1241 dag.add_model_with_format(
1242 "stg_a",
1243 "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
1244 Materialization::Table,
1245 OutputFormat::Parquet,
1246 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1247 "",
1248 )?;
1249 dag.add_model_with_format(
1250 "tf_b",
1251 "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
1252 Materialization::Table,
1253 OutputFormat::Parquet,
1254 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1255 "",
1256 )?;
1257 dag.build_graph()?;
1258
1259 let mat = MaterializeConfig {
1260 ref_strategy: RefStrategy::Parquet,
1261 memtable_max_rows: 50_000,
1262 ..Default::default()
1263 };
1264 let engine = TransformationEngine::new();
1265 let summary = engine
1266 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1267 .await?;
1268 assert_eq!(summary.models_executed, 2);
1269 assert_eq!(summary.total_rows_produced, 4);
1270 assert!(temp.path().join("tf_b.parquet").exists());
1271 Ok(())
1272 }
1273
1274 #[tokio::test]
1275 async fn test_ref_via_memtable_when_configured() -> Result<()> {
1276 use crate::core::project::{MaterializeConfig, RefStrategy};
1277
1278 let temp = tempfile::tempdir()?;
1279 let mut dag = ModelDag::new();
1280 dag.add_model_with_format(
1281 "stg_a",
1282 "SELECT 1 AS id UNION ALL SELECT 2",
1283 Materialization::Table,
1284 OutputFormat::Parquet,
1285 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1286 "",
1287 )?;
1288 dag.add_model_with_format(
1289 "tf_b",
1290 "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
1291 Materialization::Table,
1292 OutputFormat::Parquet,
1293 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1294 "",
1295 )?;
1296 dag.build_graph()?;
1297
1298 let mat = MaterializeConfig {
1299 ref_strategy: RefStrategy::Memtable,
1300 memtable_max_rows: 50_000,
1301 ..Default::default()
1302 };
1303 let engine = TransformationEngine::new();
1304 let summary = engine
1305 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1306 .await?;
1307 assert_eq!(summary.models_executed, 2);
1308 assert!(temp.path().join("tf_b.parquet").exists());
1309 Ok(())
1310 }
1311
1312 #[tokio::test]
1313 async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
1314 use crate::core::project::{MaterializeConfig, RefStrategy};
1315
1316 let temp = tempfile::tempdir()?;
1318 let mut dag = ModelDag::new();
1319 dag.add_model_with_format(
1320 "stg_a",
1321 "SELECT 1 AS id UNION ALL SELECT 2",
1322 Materialization::Table,
1323 OutputFormat::Parquet,
1324 Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1325 "",
1326 )?;
1327 dag.add_model_with_format(
1328 "tf_b",
1329 "SELECT * FROM {{ ref('stg_a') }}",
1330 Materialization::Table,
1331 OutputFormat::Parquet,
1332 Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1333 "",
1334 )?;
1335 dag.build_graph()?;
1336
1337 let mat = MaterializeConfig {
1338 ref_strategy: RefStrategy::Memtable,
1339 memtable_max_rows: 1,
1340 ..Default::default()
1341 };
1342 let engine = TransformationEngine::new();
1343 let summary = engine
1344 .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1345 .await?;
1346 assert_eq!(summary.models_executed, 2);
1347 assert_eq!(summary.total_rows_produced, 4);
1348 Ok(())
1349 }
1350}