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