1use anyhow::{bail, Context, Result};
16use async_trait::async_trait;
17use datafusion::arrow::datatypes::SchemaRef;
18use datafusion::catalog::Session;
19use datafusion::catalog::TableProvider;
20use datafusion::datasource::MemTable;
21use datafusion::error::Result as DFResult;
22use datafusion::execution::context::SessionContext;
23use datafusion::logical_expr::{Expr, TableType};
24use datafusion::physical_plan::ExecutionPlan;
25use datafusion::common::TableReference;
26use datafusion::execution::options::ArrowReadOptions;
27use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
28use rbt_core::dag::{ModelDag, ModelNode};
29use rbt_core::frontmatter::{resolve_scan_path, SourceFormat, StagingFrontmatter};
30use rbt_scan::{LakeScanner, ScanRequest};
31use std::any::Any;
32use std::collections::HashSet;
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35
36#[derive(Debug, Clone)]
38pub struct BronzeSourceMeta {
39 pub model_name: String,
40 pub source_schema: String,
41 pub source_table: String,
42 pub format: SourceFormat,
43 pub scan_path: PathBuf,
44 pub registration_mode: BronzeRegistrationMode,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum BronzeRegistrationMode {
49 DataFusionListing,
51 ScanMemTable,
53}
54
55#[derive(Debug)]
57pub struct BronzeTableProvider {
58 pub meta: BronzeSourceMeta,
59 inner: Arc<dyn TableProvider>,
60}
61
62impl BronzeTableProvider {
63 pub fn wrap(inner: Arc<dyn TableProvider>, meta: BronzeSourceMeta) -> Self {
64 Self { meta, inner }
65 }
66
67 pub fn inner(&self) -> &Arc<dyn TableProvider> {
68 &self.inner
69 }
70}
71
72#[async_trait]
73impl TableProvider for BronzeTableProvider {
74 fn as_any(&self) -> &dyn Any {
75 self
76 }
77
78 fn schema(&self) -> SchemaRef {
79 self.inner.schema()
80 }
81
82 fn table_type(&self) -> TableType {
83 self.inner.table_type()
84 }
85
86 async fn scan(
87 &self,
88 state: &dyn Session,
89 projection: Option<&Vec<usize>>,
90 filters: &[Expr],
91 limit: Option<usize>,
92 ) -> DFResult<Arc<dyn ExecutionPlan>> {
93 self.inner.scan(state, projection, filters, limit).await
94 }
95
96 fn supports_filters_pushdown(
97 &self,
98 filters: &[&Expr],
99 ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
100 self.inner.supports_filters_pushdown(filters)
101 }
102}
103
104pub async fn register_bronze_sources_for_dag(
108 ctx: &SessionContext,
109 dag: &ModelDag,
110 project_dir: &Path,
111 registered: &mut HashSet<(String, String)>,
112) -> Result<usize> {
113 let mut count = 0;
114 for idx in dag.graph.node_indices() {
115 let node = &dag.graph[idx];
116 if let Some(n) = register_bronze_for_model(ctx, node, project_dir, registered).await? {
117 count += n;
118 }
119 }
120 Ok(count)
121}
122
123pub async fn register_bronze_for_model(
125 ctx: &SessionContext,
126 node: &ModelNode,
127 project_dir: &Path,
128 registered: &mut HashSet<(String, String)>,
129) -> Result<Option<usize>> {
130 let Some(fm) = node.frontmatter.as_ref() else {
131 return Ok(None);
132 };
133 if !fm.has_scan_contract() {
134 return Ok(None);
135 }
136
137 let (schema_name, table_name) = ModelDag::bronze_source_ident(node).with_context(|| {
138 format!(
139 "model '{}': frontmatter has scan_path but no source identity \
140 (add source() in SQL or source_name/source_table in frontmatter)",
141 node.name
142 )
143 })?;
144
145 let key = (schema_name.clone(), table_name.clone());
146 if registered.contains(&key) {
147 tracing::debug!(
148 "Bronze source {}.{} already registered; skipping model '{}'",
149 schema_name,
150 table_name,
151 node.name
152 );
153 return Ok(None);
154 }
155
156 ensure_schema(ctx, &schema_name).await?;
157
158 let format = fm.resolve_format().with_context(|| {
159 format!("model '{}': cannot resolve source_format", node.name)
160 })?;
161
162 let resolved = resolve_scan_path(project_dir, fm.scan_path.as_deref().unwrap());
163 if !resolved.exists() && !rbt_core::frontmatter::is_remote_uri(fm.scan_path.as_deref().unwrap())
164 {
165 bail!(
166 "model '{}': bronze scan_path does not exist: {} (resolved {})",
167 node.name,
168 fm.scan_path.as_deref().unwrap(),
169 resolved.display()
170 );
171 }
172
173 let path_str = resolved.to_string_lossy().to_string();
174 let use_scan = should_use_scan_path(fm, format);
175
176 let (inner, mode) = if use_scan {
177 let provider = scan_to_memtable(project_dir, fm, format)
178 .await
179 .with_context(|| format!("model '{}': bronze scan failed", node.name))?;
180 (provider, BronzeRegistrationMode::ScanMemTable)
181 } else {
182 let provider = listing_table_provider(ctx, &path_str, format)
183 .await
184 .with_context(|| {
185 format!(
186 "model '{}': DataFusion listing registration failed for {}",
187 node.name, path_str
188 )
189 })?;
190 (provider, BronzeRegistrationMode::DataFusionListing)
191 };
192
193 let meta = BronzeSourceMeta {
194 model_name: node.name.clone(),
195 source_schema: schema_name.clone(),
196 source_table: table_name.clone(),
197 format,
198 scan_path: resolved,
199 registration_mode: mode,
200 };
201
202 let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
203 let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
204
205 let _ = ctx.deregister_table(table_ref.clone());
207 ctx.register_table(table_ref, bronze)
208 .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
209
210 registered.insert(key);
211 tracing::info!(
212 "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
213 schema_name,
214 table_name,
215 node.name,
216 mode,
217 format
218 );
219 Ok(Some(1))
220}
221
222fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
223 if fm.force_scan.unwrap_or(false) {
224 return true;
225 }
226 if fm
229 .partition_by
230 .as_ref()
231 .map(|p| !p.is_empty())
232 .unwrap_or(false)
233 || fm
234 .require_partitions
235 .as_ref()
236 .map(|p| !p.is_empty())
237 .unwrap_or(false)
238 || fm.inject_source_path.unwrap_or(false)
239 {
240 return true;
241 }
242 if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
244 && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
245 {
246 return true;
247 }
248 matches!(
250 format,
251 SourceFormat::Log
252 | SourceFormat::Txt
253 | SourceFormat::Toml
254 | SourceFormat::ArrowIpc
255 | SourceFormat::ArrowIpcStream
256 )
257}
258
259async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
260 let sql = format!("CREATE SCHEMA IF NOT EXISTS \"{}\"", schema_name.replace('"', ""));
262 ctx.sql(&sql)
263 .await
264 .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
265 .collect()
266 .await
267 .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
268 Ok(())
269}
270
271async fn listing_table_provider(
273 ctx: &SessionContext,
274 path: &str,
275 format: SourceFormat,
276) -> Result<Arc<dyn TableProvider>> {
277 let tmp = format!(
279 "__rbt_bronze_tmp_{}",
280 std::time::SystemTime::now()
281 .duration_since(std::time::UNIX_EPOCH)
282 .map(|d| d.as_nanos())
283 .unwrap_or(0)
284 );
285
286 match format {
287 SourceFormat::Parquet => {
288 ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
289 .await?;
290 }
291 SourceFormat::Csv => {
292 ctx.register_csv(&tmp, path, CsvReadOptions::default())
293 .await?;
294 }
295 SourceFormat::Jsonl => {
296 let opts = JsonReadOptions::default()
297 .file_extension(".jsonl")
298 .newline_delimited(true);
299 if let Err(e) = ctx.register_json(&tmp, path, opts).await {
301 tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
303 ctx.register_json(&tmp, path, JsonReadOptions::default())
304 .await?;
305 }
306 }
307 SourceFormat::Json => {
308 let opts = JsonReadOptions::default().newline_delimited(false);
309 ctx.register_json(&tmp, path, opts).await?;
310 }
311 SourceFormat::ArrowIpc => {
312 ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
313 .await?;
314 }
315 other => bail!("listing_table_provider does not support format {}", other),
316 }
317
318 let provider = ctx
319 .table_provider(TableReference::bare(tmp.as_str()))
320 .await
321 .with_context(|| format!("lookup temp bronze table {}", tmp))?;
322 let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
323 Ok(provider)
324}
325
326async fn scan_to_memtable(
327 project_dir: &Path,
328 fm: &StagingFrontmatter,
329 format: SourceFormat,
330) -> Result<Arc<dyn TableProvider>> {
331 let mut req = ScanRequest::from_frontmatter(project_dir, fm)?;
332 req.format = format;
333 let scanner = LakeScanner::from_request(&req);
334 let batches = scanner.scan(&req).await?;
335 if batches.is_empty() {
336 bail!(
337 "bronze scan produced zero batches for {}",
338 req.resolved_path().display()
339 );
340 }
341 let schema = batches[0].schema();
342 let mem = MemTable::try_new(schema, vec![batches])
344 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
345 Ok(Arc::new(mem))
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use rbt_core::dag::{Materialization, ModelDag, OutputFormat};
352
353 #[tokio::test]
354 async fn register_jsonl_from_frontmatter() -> Result<()> {
355 let temp = tempfile::tempdir()?;
356 let bronze = temp.path().join("raw.jsonl");
357 std::fs::write(
358 &bronze,
359 r#"{"ticker":"NVDA","price":1.5}
360{"ticker":"AAPL","price":2.5}
361"#,
362 )?;
363
364 let sql = format!(
365 r#"---
366source_format: jsonl
367scan_path: "{}"
368---
369SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
370"#,
371 bronze.file_name().unwrap().to_string_lossy()
372 );
373
374 let mut dag = ModelDag::new();
375 dag.add_model_with_format(
376 "stg_trades",
377 &sql,
378 Materialization::Table,
379 OutputFormat::Parquet,
380 None,
381 "",
382 )?;
383 dag.build_graph()?;
384
385 let engine_ctx = SessionContext::new();
386 let mut registered = HashSet::new();
387 let n =
388 register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered)
389 .await?;
390 assert_eq!(n, 1);
391
392 let df = engine_ctx
393 .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
394 .await?;
395 let batches = df.collect().await?;
396 assert_eq!(batches[0].num_rows(), 1);
397 Ok(())
398 }
399}