1use crate::core::dag::{ModelDag, ModelNode};
16use crate::core::frontmatter::{resolve_scan_path, SourceFormat, StagingFrontmatter};
17use crate::scan::{LakeScanner, ScanRequest};
18use anyhow::{bail, Context, Result};
19use async_trait::async_trait;
20use datafusion::arrow::datatypes::SchemaRef;
21use datafusion::catalog::Session;
22use datafusion::catalog::TableProvider;
23use datafusion::common::TableReference;
24use datafusion::datasource::MemTable;
25use datafusion::error::Result as DFResult;
26use datafusion::execution::context::SessionContext;
27use datafusion::execution::options::ArrowReadOptions;
28use datafusion::logical_expr::{Expr, TableType};
29use datafusion::physical_plan::ExecutionPlan;
30use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
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
159 .resolve_format()
160 .with_context(|| format!("model '{}': cannot resolve source_format", node.name))?;
161
162 let resolved = resolve_scan_path(project_dir, fm.scan_path.as_deref().unwrap());
163 if !resolved.exists()
164 && !crate::core::frontmatter::is_remote_uri(fm.scan_path.as_deref().unwrap())
165 {
166 bail!(
167 "model '{}': bronze scan_path does not exist: {} (resolved {})",
168 node.name,
169 fm.scan_path.as_deref().unwrap(),
170 resolved.display()
171 );
172 }
173
174 let path_str = resolved.to_string_lossy().to_string();
175 let use_scan = should_use_scan_path(fm, format);
176
177 let (inner, mode) = if use_scan {
178 let provider = scan_to_memtable(project_dir, fm, format)
179 .await
180 .with_context(|| format!("model '{}': bronze scan failed", node.name))?;
181 (provider, BronzeRegistrationMode::ScanMemTable)
182 } else {
183 let provider = listing_table_provider(ctx, &path_str, format)
184 .await
185 .with_context(|| {
186 format!(
187 "model '{}': DataFusion listing registration failed for {}",
188 node.name, path_str
189 )
190 })?;
191 (provider, BronzeRegistrationMode::DataFusionListing)
192 };
193
194 let meta = BronzeSourceMeta {
195 model_name: node.name.clone(),
196 source_schema: schema_name.clone(),
197 source_table: table_name.clone(),
198 format,
199 scan_path: resolved,
200 registration_mode: mode,
201 };
202
203 let bronze = Arc::new(BronzeTableProvider::wrap(inner, meta));
204 let table_ref = TableReference::partial(schema_name.clone(), table_name.clone());
205
206 let _ = ctx.deregister_table(table_ref.clone());
208 ctx.register_table(table_ref, bronze)
209 .map_err(|e| anyhow::anyhow!("register {}.{}: {}", schema_name, table_name, e))?;
210
211 registered.insert(key);
212 tracing::info!(
213 "Registered bronze source {}.{} from model '{}' ({:?}, format={})",
214 schema_name,
215 table_name,
216 node.name,
217 mode,
218 format
219 );
220 Ok(Some(1))
221}
222
223fn should_use_scan_path(fm: &StagingFrontmatter, format: SourceFormat) -> bool {
224 if fm.force_scan.unwrap_or(false) {
225 return true;
226 }
227 if fm
230 .partition_by
231 .as_ref()
232 .map(|p| !p.is_empty())
233 .unwrap_or(false)
234 || fm
235 .require_partitions
236 .as_ref()
237 .map(|p| !p.is_empty())
238 .unwrap_or(false)
239 || fm.inject_source_path.unwrap_or(false)
240 {
241 return true;
242 }
243 if matches!(format, SourceFormat::Jsonl | SourceFormat::Json)
245 && fm.paths.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
246 {
247 return true;
248 }
249 matches!(
251 format,
252 SourceFormat::Log
253 | SourceFormat::Txt
254 | SourceFormat::Toml
255 | SourceFormat::ArrowIpc
256 | SourceFormat::ArrowIpcStream
257 )
258}
259
260async fn ensure_schema(ctx: &SessionContext, schema_name: &str) -> Result<()> {
261 let sql = format!(
263 "CREATE SCHEMA IF NOT EXISTS \"{}\"",
264 schema_name.replace('"', "")
265 );
266 ctx.sql(&sql)
267 .await
268 .with_context(|| format!("CREATE SCHEMA {}", schema_name))?
269 .collect()
270 .await
271 .with_context(|| format!("CREATE SCHEMA {} collect", schema_name))?;
272 Ok(())
273}
274
275async fn listing_table_provider(
277 ctx: &SessionContext,
278 path: &str,
279 format: SourceFormat,
280) -> Result<Arc<dyn TableProvider>> {
281 let tmp = format!(
283 "__rbt_bronze_tmp_{}",
284 std::time::SystemTime::now()
285 .duration_since(std::time::UNIX_EPOCH)
286 .map(|d| d.as_nanos())
287 .unwrap_or(0)
288 );
289
290 match format {
291 SourceFormat::Parquet => {
292 ctx.register_parquet(&tmp, path, ParquetReadOptions::default())
293 .await?;
294 }
295 SourceFormat::Csv => {
296 ctx.register_csv(&tmp, path, CsvReadOptions::default())
297 .await?;
298 }
299 SourceFormat::Jsonl => {
300 let opts = JsonReadOptions::default()
301 .file_extension(".jsonl")
302 .newline_delimited(true);
303 if let Err(e) = ctx.register_json(&tmp, path, opts).await {
305 tracing::debug!("jsonl register with .jsonl failed ({e}); retrying default");
307 ctx.register_json(&tmp, path, JsonReadOptions::default())
308 .await?;
309 }
310 }
311 SourceFormat::Json => {
312 let opts = JsonReadOptions::default().newline_delimited(false);
313 ctx.register_json(&tmp, path, opts).await?;
314 }
315 SourceFormat::ArrowIpc => {
316 ctx.register_arrow(&tmp, path, ArrowReadOptions::default())
317 .await?;
318 }
319 other => bail!("listing_table_provider does not support format {}", other),
320 }
321
322 let provider = ctx
323 .table_provider(TableReference::bare(tmp.as_str()))
324 .await
325 .with_context(|| format!("lookup temp bronze table {}", tmp))?;
326 let _ = ctx.deregister_table(TableReference::bare(tmp.as_str()))?;
327 Ok(provider)
328}
329
330async fn scan_to_memtable(
331 project_dir: &Path,
332 fm: &StagingFrontmatter,
333 format: SourceFormat,
334) -> Result<Arc<dyn TableProvider>> {
335 let mut req = ScanRequest::from_frontmatter(project_dir, fm)?;
336 req.format = format;
337 let scanner = LakeScanner::from_request(&req);
338 let batches = scanner.scan(&req).await?;
339 if batches.is_empty() {
340 bail!(
341 "bronze scan produced zero batches for {}",
342 req.resolved_path().display()
343 );
344 }
345 let schema = batches[0].schema();
346 let mem = MemTable::try_new(schema, vec![batches])
348 .map_err(|e| anyhow::anyhow!("MemTable::try_new: {}", e))?;
349 Ok(Arc::new(mem))
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::core::dag::{Materialization, ModelDag, OutputFormat};
356
357 #[tokio::test]
358 async fn register_jsonl_from_frontmatter() -> Result<()> {
359 let temp = tempfile::tempdir()?;
360 let bronze = temp.path().join("raw.jsonl");
361 std::fs::write(
362 &bronze,
363 r#"{"ticker":"NVDA","price":1.5}
364{"ticker":"AAPL","price":2.5}
365"#,
366 )?;
367
368 let sql = format!(
369 r#"---
370source_format: jsonl
371scan_path: "{}"
372---
373SELECT ticker, price FROM {{{{ source('bronze', 'raw_trades') }}}}
374"#,
375 bronze.file_name().unwrap().to_string_lossy()
376 );
377
378 let mut dag = ModelDag::new();
379 dag.add_model_with_format(
380 "stg_trades",
381 &sql,
382 Materialization::Table,
383 OutputFormat::Parquet,
384 None,
385 "",
386 )?;
387 dag.build_graph()?;
388
389 let engine_ctx = SessionContext::new();
390 let mut registered = HashSet::new();
391 let n = register_bronze_sources_for_dag(&engine_ctx, &dag, temp.path(), &mut registered)
392 .await?;
393 assert_eq!(n, 1);
394
395 let df = engine_ctx
396 .sql("SELECT COUNT(*) AS c FROM bronze.raw_trades")
397 .await?;
398 let batches = df.collect().await?;
399 assert_eq!(batches[0].num_rows(), 1);
400 Ok(())
401 }
402}