1pub mod bronze;
4
5use anyhow::{Context, Result};
6use datafusion::datasource::MemTable;
7use datafusion::execution::context::SessionContext;
8use datafusion::physical_plan::SendableRecordBatchStream;
9use iceberg::Catalog;
10use iceberg_datafusion::IcebergCatalogProvider;
11use rbt_core::dag::{ModelDag, OutputFormat};
12use rbt_materializer::MultiFormatWriter;
13use rbt_testing::{assertions_from_model_tests, RecordBatchValidator};
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18pub use bronze::{
19 register_bronze_for_model, register_bronze_sources_for_dag, BronzeRegistrationMode,
20 BronzeSourceMeta, BronzeTableProvider,
21};
22
23#[derive(Debug, Clone)]
25pub struct DagExecutionSummary {
26 pub models_executed: usize,
27 pub total_rows_produced: usize,
28 pub bronze_sources_registered: usize,
29}
30
31#[derive(Default)]
33pub struct RbtEngineBuilder {
34 catalogs: Vec<(String, Arc<dyn Catalog>)>,
35}
36
37impl RbtEngineBuilder {
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
43 self.catalogs.push((name.into(), catalog));
44 self
45 }
46
47 pub async fn build(self) -> Result<TransformationEngine> {
48 let engine = TransformationEngine::new();
49 for (name, cat) in self.catalogs {
50 engine.register_iceberg_catalog(&name, cat).await?;
51 }
52 Ok(engine)
53 }
54}
55
56pub struct TransformationEngine {
57 pub ctx: SessionContext,
58}
59
60impl Default for TransformationEngine {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl TransformationEngine {
67 pub fn new() -> Self {
68 Self {
69 ctx: SessionContext::new(),
70 }
71 }
72
73 pub async fn register_iceberg_catalog(
75 &self,
76 catalog_name: &str,
77 catalog: Arc<dyn Catalog>,
78 ) -> Result<()> {
79 tracing::info!(
80 "Registering Iceberg catalog '{}' into DataFusion SessionContext",
81 catalog_name
82 );
83 let provider = IcebergCatalogProvider::try_new(catalog).await?;
84 self.ctx.register_catalog(catalog_name, Arc::new(provider));
85 Ok(())
86 }
87
88 pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
90 tracing::info!(
91 "Executing SQL transform via Apache DataFusion engine: {}",
92 sql
93 );
94 let df = self.ctx.sql(sql).await?;
95 let stream = df.execute_stream().await?;
96 Ok(stream)
97 }
98
99 pub async fn execute_dag(
104 &self,
105 dag: &ModelDag,
106 project_dir: impl AsRef<Path>,
107 output_dir: impl AsRef<Path>,
108 ) -> Result<DagExecutionSummary> {
109 let project_dir = project_dir.as_ref();
110 let output_base = output_dir.as_ref();
111 tokio::fs::create_dir_all(output_base).await?;
112
113 let mut registered = HashSet::new();
114 let bronze_sources_registered =
115 register_bronze_sources_for_dag(&self.ctx, dag, project_dir, &mut registered)
116 .await
117 .context("frontmatter-driven bronze registration failed")?;
118
119 let tiers = dag.execution_tiers()?;
120 let mut models_executed = 0;
121 let mut total_rows_produced = 0;
122
123 for (tier_idx, tier) in tiers.iter().enumerate() {
124 tracing::info!(
125 "Executing DAG Tier {} with {} parallel models",
126 tier_idx,
127 tier.len()
128 );
129
130 for model in tier {
131 tracing::info!("Executing model '{}'...", model.name);
132
133 register_bronze_for_model(&self.ctx, model, project_dir, &mut registered)
135 .await?;
136
137 let df = self
138 .ctx
139 .sql(&model.compiled_sql)
140 .await
141 .with_context(|| {
142 format!(
143 "SQL execution failed for model '{}' (compiled: {})",
144 model.name, model.compiled_sql
145 )
146 })?;
147 let batches = df.collect().await.with_context(|| {
148 format!("collect failed for model '{}'", model.name)
149 })?;
150 let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
151
152 let dest_path = model.output_path.as_ref().map(PathBuf::from).unwrap_or_else(
153 || match model.output_format {
154 OutputFormat::Iceberg => output_base.join(&model.name),
155 OutputFormat::Jsonl => {
156 output_base.join(format!("{}.jsonl", model.name))
157 }
158 OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
159 _ => output_base.join(format!("{}.parquet", model.name)),
160 },
161 );
162
163 if let Some(parent) = dest_path.parent() {
164 std::fs::create_dir_all(parent)?;
165 }
166
167 MultiFormatWriter::write_batches(&batches, &model.output_format, &dest_path)?;
168
169 if let Some(fm) = model.frontmatter.as_ref() {
171 if let Some(tests) = fm.tests.as_ref() {
172 if !tests.is_empty() {
173 let unique = tests
174 .unique
175 .clone()
176 .or_else(|| fm.unique_key.clone())
177 .or_else(|| fm.grain.clone());
178 let assertions = assertions_from_model_tests(
179 tests.not_null.as_deref(),
180 unique.as_deref(),
181 tests.accepted_values.as_ref(),
182 );
183 if !assertions.is_empty() {
184 let result =
185 RecordBatchValidator::validate_batches(&batches, &assertions);
186 if result.failed_assertions > 0 {
187 let msg = format!(
188 "model '{}' failed {} test(s): {}",
189 model.name,
190 result.failed_assertions,
191 result.errors.join("; ")
192 );
193 if tests.should_fail_on_error() {
194 anyhow::bail!(msg);
195 }
196 tracing::warn!("{}", msg);
197 } else {
198 tracing::info!(
199 "model '{}': {} assertion(s) passed ({} rows)",
200 model.name,
201 result.passed_assertions,
202 result.total_rows
203 );
204 }
205 }
206 }
207 } else if let Some(uk) = fm
208 .unique_key
209 .as_ref()
210 .or(fm.grain.as_ref())
211 .filter(|v| !v.is_empty())
212 {
213 let assertions = assertions_from_model_tests(None, Some(uk.as_slice()), None);
215 let result =
216 RecordBatchValidator::validate_batches(&batches, &assertions);
217 if result.failed_assertions > 0 {
218 anyhow::bail!(
219 "model '{}' grain/unique_key violated: {}",
220 model.name,
221 result.errors.join("; ")
222 );
223 }
224 }
225 }
226
227 if !batches.is_empty() {
228 let schema = batches[0].schema();
229 let mem_table = MemTable::try_new(schema, vec![batches.clone()])?;
230 let _ = self.ctx.deregister_table(model.name.as_str());
232 self.ctx
233 .register_table(model.name.as_str(), Arc::new(mem_table))?;
234 }
235
236 models_executed += 1;
237 total_rows_produced += row_count;
238 }
239 }
240
241 Ok(DagExecutionSummary {
242 models_executed,
243 total_rows_produced,
244 bronze_sources_registered,
245 })
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use rbt_core::dag::{Materialization, ModelDag, OutputFormat};
253
254 #[tokio::test]
255 async fn test_engine_initialization() -> Result<()> {
256 let engine = TransformationEngine::new();
257 let df = engine.ctx.sql("SELECT 1 AS col").await?;
258 let batches = df.collect().await?;
259 assert_eq!(batches.len(), 1);
260 assert_eq!(batches[0].num_rows(), 1);
261 Ok(())
262 }
263
264 #[tokio::test]
265 async fn test_dag_execution_multi_format() -> Result<()> {
266 let temp_dir = tempfile::tempdir()?;
267 let engine = TransformationEngine::new();
268
269 let mut dag = ModelDag::new();
270 dag.add_model_with_format(
271 "users",
272 "SELECT 1 AS id, 'Alice' AS name",
273 Materialization::Table,
274 OutputFormat::Jsonl,
275 None,
276 "",
277 )?;
278 dag.add_model_with_format(
279 "active_users",
280 "SELECT * FROM {{ ref('users') }} WHERE id = 1",
281 Materialization::Table,
282 OutputFormat::Parquet,
283 None,
284 "",
285 )?;
286 dag.build_graph()?;
287
288 let summary = engine
289 .execute_dag(&dag, temp_dir.path(), temp_dir.path())
290 .await?;
291 assert_eq!(summary.models_executed, 2);
292 assert_eq!(summary.total_rows_produced, 2);
293 assert!(temp_dir.path().join("users.jsonl").exists());
294 assert!(temp_dir.path().join("active_users.parquet").exists());
295 Ok(())
296 }
297
298 #[tokio::test]
299 async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
300 let temp = tempfile::tempdir()?;
301 let bronze_dir = temp.path().join("lake/bronze");
302 std::fs::create_dir_all(&bronze_dir)?;
303 std::fs::write(
304 bronze_dir.join("raw_stock_trades.jsonl"),
305 r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
306{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
307"#,
308 )?;
309
310 let sql = r#"---
311source_format: jsonl
312scan_path: "lake/bronze/raw_stock_trades.jsonl"
313---
314SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
315"#;
316
317 let mut dag = ModelDag::new();
318 dag.add_model_with_format(
319 "stg_stock_trades",
320 sql,
321 Materialization::Table,
322 OutputFormat::Parquet,
323 Some(
324 temp.path()
325 .join("lake/silver/stg_stock_trades.parquet")
326 .to_string_lossy()
327 .into(),
328 ),
329 "",
330 )?;
331 dag.build_graph()?;
332
333 let engine = TransformationEngine::new();
334 let summary = engine.execute_dag(&dag, temp.path(), temp.path().join("out")).await?;
335 assert_eq!(summary.bronze_sources_registered, 1);
336 assert_eq!(summary.models_executed, 1);
337 assert_eq!(summary.total_rows_produced, 2);
338 assert!(temp
339 .path()
340 .join("lake/silver/stg_stock_trades.parquet")
341 .exists());
342 Ok(())
343 }
344}