1use crate::core::run_scope::OnMissing;
4use anyhow::{bail, Context, Result};
5use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum BronzeCheckMode {
15 Off,
17 #[default]
19 Warn,
20 Fail,
22}
23
24impl BronzeCheckMode {
25 pub fn as_str(self) -> &'static str {
26 match self {
27 Self::Off => "off",
28 Self::Warn => "warn",
29 Self::Fail => "fail",
30 }
31 }
32}
33
34impl fmt::Display for BronzeCheckMode {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.write_str(self.as_str())
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SourceFormat {
44 #[serde(alias = "ndjson")]
46 Jsonl,
47 Json,
49 Parquet,
50 Csv,
51 #[serde(alias = "arrow", alias = "arrow_file", alias = "ipc")]
53 ArrowIpc,
54 #[serde(alias = "arrow_stream", alias = "ipc_stream")]
56 ArrowIpcStream,
57 Log,
59 Txt,
61 Toml,
63 #[serde(alias = "pb", alias = "proto")]
68 Protobuf,
69}
70
71impl SourceFormat {
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Jsonl => "jsonl",
75 Self::Json => "json",
76 Self::Parquet => "parquet",
77 Self::Csv => "csv",
78 Self::ArrowIpc => "arrow_ipc",
79 Self::ArrowIpcStream => "arrow_ipc_stream",
80 Self::Log => "log",
81 Self::Txt => "txt",
82 Self::Toml => "toml",
83 Self::Protobuf => "protobuf",
84 }
85 }
86
87 pub fn prefers_datafusion_listing(self) -> bool {
89 matches!(self, Self::Parquet | Self::Csv | Self::Json | Self::Jsonl)
90 }
91
92 pub fn from_extension(ext: &str) -> Option<Self> {
94 match ext.to_ascii_lowercase().as_str() {
95 "jsonl" | "ndjson" => Some(Self::Jsonl),
96 "json" => Some(Self::Json),
97 "parquet" | "pq" => Some(Self::Parquet),
98 "csv" | "tsv" => Some(Self::Csv),
99 "arrow" | "arrows" | "ipc" | "feather" => Some(Self::ArrowIpc),
100 "arrows_stream" | "ipc_stream" => Some(Self::ArrowIpcStream),
101 "log" => Some(Self::Log),
102 "txt" | "text" | "md" => Some(Self::Txt),
103 "toml" => Some(Self::Toml),
104 "pb" | "protobuf" | "protobin" => Some(Self::Protobuf),
105 _ => None,
106 }
107 }
108
109 pub fn parse(s: &str) -> Result<Self> {
111 let key = s.trim().to_ascii_lowercase().replace('-', "_");
112 match key.as_str() {
113 "jsonl" | "ndjson" | "json_lines" => Ok(Self::Jsonl),
114 "json" => Ok(Self::Json),
115 "parquet" | "pq" => Ok(Self::Parquet),
116 "csv" | "tsv" => Ok(Self::Csv),
117 "arrow_ipc" | "arrow" | "arrow_file" | "ipc" | "feather" => Ok(Self::ArrowIpc),
118 "arrow_ipc_stream" | "arrow_stream" | "ipc_stream" => Ok(Self::ArrowIpcStream),
119 "log" => Ok(Self::Log),
120 "txt" | "text" => Ok(Self::Txt),
121 "toml" => Ok(Self::Toml),
122 "protobuf" | "pb" | "proto" | "protobin" => Ok(Self::Protobuf),
123 other => bail!(
124 "Unknown source_format '{}'. Expected one of: jsonl, json, parquet, csv, arrow_ipc, arrow_ipc_stream, log, txt, toml, protobuf",
125 other
126 ),
127 }
128 }
129}
130
131impl fmt::Display for SourceFormat {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(self.as_str())
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct RelationshipTest {
141 pub column: String,
143 #[serde(alias = "to", alias = "ref")]
145 pub to_model: String,
146 #[serde(default, alias = "field")]
148 pub to_column: Option<String>,
149}
150
151impl RelationshipTest {
152 pub fn parent_column(&self) -> &str {
153 self.to_column.as_deref().unwrap_or(self.column.as_str())
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
159pub struct ModelTests {
160 #[serde(default)]
162 pub not_null: Option<Vec<String>>,
163 #[serde(default)]
165 pub unique: Option<Vec<String>>,
166 #[serde(default)]
168 pub accepted_values: Option<std::collections::HashMap<String, Vec<String>>>,
169 #[serde(default)]
171 pub relationships: Option<Vec<RelationshipTest>>,
172 #[serde(default)]
174 pub fail_on_error: Option<bool>,
175}
176
177impl ModelTests {
178 pub fn is_empty(&self) -> bool {
179 self.not_null.as_ref().map(|v| v.is_empty()).unwrap_or(true)
180 && self.unique.as_ref().map(|v| v.is_empty()).unwrap_or(true)
181 && self
182 .accepted_values
183 .as_ref()
184 .map(|m| m.is_empty())
185 .unwrap_or(true)
186 && self
187 .relationships
188 .as_ref()
189 .map(|r| r.is_empty())
190 .unwrap_or(true)
191 }
192
193 pub fn should_fail_on_error(&self) -> bool {
194 self.fail_on_error.unwrap_or(true)
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
203pub struct ColumnMeta {
204 #[serde(default)]
205 pub description: Option<String>,
206 #[serde(default)]
207 pub context: Option<String>,
208 #[serde(default)]
210 pub dtype: Option<String>,
211 #[serde(default)]
213 pub unit: Option<String>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
221pub struct StagingFrontmatter {
222 #[serde(default)]
224 pub description: Option<String>,
225 #[serde(default)]
227 pub context: Option<String>,
228 #[serde(default)]
230 pub columns: Option<std::collections::BTreeMap<String, ColumnMeta>>,
231
232 #[serde(default)]
234 pub source_format: Option<SourceFormat>,
235 #[serde(default)]
237 pub scan_path: Option<String>,
238 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
248 pub path_glob: Option<Vec<String>>,
249 #[serde(default)]
251 pub partition_by: Option<Vec<String>>,
252 #[serde(default)]
254 pub paths: Option<Vec<String>>,
255 #[serde(default)]
257 pub source_name: Option<String>,
258 #[serde(default)]
260 pub source_table: Option<String>,
261 #[serde(default)]
263 pub toml_rows_key: Option<String>,
264 #[serde(default)]
266 pub force_scan: Option<bool>,
267 #[serde(default)]
270 pub require_partitions: Option<std::collections::HashMap<String, String>>,
271 #[serde(default)]
274 pub inject_source_path: Option<bool>,
275 #[serde(default)]
280 pub on_missing: Option<OnMissing>,
281 #[serde(default)]
285 pub stage_mode: Option<String>,
286 #[serde(default)]
289 pub parts: Option<bool>,
290 #[serde(default)]
293 pub lineage_stamp: Option<bool>,
294
295 #[serde(default)]
297 pub grain: Option<Vec<String>>,
298 #[serde(default)]
300 pub unique_key: Option<Vec<String>>,
301 #[serde(default)]
303 pub tags: Option<Vec<String>>,
304 #[serde(default)]
306 pub materialization: Option<String>,
307 #[serde(default)]
309 pub tests: Option<ModelTests>,
310 #[serde(default)]
312 pub meta: Option<std::collections::BTreeMap<String, serde_yaml::Value>>,
313}
314
315impl StagingFrontmatter {
316 pub fn resolve_format(&self) -> Result<SourceFormat> {
318 if let Some(fmt) = self.source_format {
319 return Ok(fmt);
320 }
321 let path = self
322 .scan_path
323 .as_deref()
324 .context("frontmatter missing both source_format and scan_path")?;
325 let candidate = path.rsplit('/').next().unwrap_or(path);
327 let candidate = candidate.trim_matches(|c| c == '*' || c == '?');
328 if let Some(ext) = Path::new(candidate).extension().and_then(|e| e.to_str()) {
329 if let Some(fmt) = SourceFormat::from_extension(ext) {
330 return Ok(fmt);
331 }
332 }
333 bail!(
335 "Cannot infer source_format from scan_path '{}'; set source_format explicitly",
336 path
337 );
338 }
339
340 pub fn has_scan_contract(&self) -> bool {
341 self.scan_path
342 .as_ref()
343 .map(|s| !s.trim().is_empty())
344 .unwrap_or(false)
345 }
346
347 pub fn on_missing_policy(&self) -> OnMissing {
348 self.on_missing.unwrap_or(OnMissing::Error)
349 }
350
351 pub fn wants_lineage_stamp(&self) -> bool {
352 self.lineage_stamp.unwrap_or(false)
353 }
354
355 pub fn wants_parts_source(&self) -> bool {
356 self.parts.unwrap_or(false)
357 }
358
359 pub fn empty_frame_schema(&self) -> Result<SchemaRef> {
364 let mut fields: Vec<Field> = Vec::new();
365 let mut seen = std::collections::HashSet::new();
366
367 if let Some(cols) = &self.columns {
368 for (name, meta) in cols {
369 let dtype = meta
370 .dtype
371 .as_deref()
372 .with_context(|| {
373 format!(
374 "E_RBT_EMPTY_SCHEMA: column '{name}' needs dtype: for on_missing: empty \
375 (e.g. utf8, int64, float64, bool, binary, timestamp)"
376 )
377 })?;
378 let dt = parse_logical_dtype(dtype).with_context(|| {
379 format!("E_RBT_EMPTY_SCHEMA: column '{name}' dtype '{dtype}'")
380 })?;
381 fields.push(Field::new(name, dt, true));
382 seen.insert(name.clone());
383 }
384 }
385
386 if let Some(parts) = &self.partition_by {
387 for p in parts {
388 if seen.insert(p.clone()) {
389 fields.push(Field::new(p, DataType::Utf8, true));
390 }
391 }
392 }
393
394 if self.inject_source_path.unwrap_or(false) && seen.insert("_source_path".into()) {
395 fields.push(Field::new("_source_path", DataType::Utf8, true));
396 }
397
398 if fields.is_empty() {
399 bail!(
400 "E_RBT_EMPTY_SCHEMA: on_missing: empty requires columns with dtype \
401 and/or partition_by (model scan contract has no schema fields)"
402 );
403 }
404 Ok(Arc::new(Schema::new(fields)))
405 }
406}
407
408pub fn parse_logical_dtype(s: &str) -> Result<DataType> {
410 let key = s.trim().to_ascii_lowercase().replace('-', "_");
411 Ok(match key.as_str() {
412 "utf8" | "string" | "str" | "varchar" | "text" => DataType::Utf8,
413 "int64" | "long" | "bigint" | "i64" => DataType::Int64,
414 "int32" | "int" | "i32" => DataType::Int32,
415 "int16" | "smallint" | "i16" => DataType::Int16,
416 "int8" | "tinyint" | "i8" => DataType::Int8,
417 "uint64" | "u64" => DataType::UInt64,
418 "uint32" | "u32" => DataType::UInt32,
419 "float64" | "double" | "f64" => DataType::Float64,
420 "float32" | "float" | "f32" => DataType::Float32,
421 "bool" | "boolean" => DataType::Boolean,
422 "binary" | "bytes" | "blob" => DataType::Binary,
423 "date" | "date32" => DataType::Date32,
424 "timestamp" | "timestamp_us" | "timestamptz" => {
425 DataType::Timestamp(TimeUnit::Microsecond, None)
426 }
427 "timestamp_ms" => DataType::Timestamp(TimeUnit::Millisecond, None),
428 "timestamp_ns" => DataType::Timestamp(TimeUnit::Nanosecond, None),
429 "timestamp_s" => DataType::Timestamp(TimeUnit::Second, None),
430 other => bail!(
431 "unknown dtype '{other}' (expected utf8|int64|int32|float64|bool|binary|date|timestamp…)"
432 ),
433 })
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum DiagnosticSeverity {
439 Warning,
440 Error,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct BronzeDiagnostic {
446 pub model: String,
447 pub severity: DiagnosticSeverity,
448 pub code: &'static str,
449 pub message: String,
450}
451
452impl fmt::Display for BronzeDiagnostic {
453 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454 let level = match self.severity {
455 DiagnosticSeverity::Warning => "warning",
456 DiagnosticSeverity::Error => "error",
457 };
458 write!(
459 f,
460 "{}[{}] model={}: {}",
461 level, self.code, self.model, self.message
462 )
463 }
464}
465
466#[derive(Debug, Clone, Default)]
468pub struct BronzeValidationReport {
469 pub diagnostics: Vec<BronzeDiagnostic>,
470}
471
472impl BronzeValidationReport {
473 pub fn warning_count(&self) -> usize {
474 self.diagnostics
475 .iter()
476 .filter(|d| d.severity == DiagnosticSeverity::Warning)
477 .count()
478 }
479
480 pub fn error_count(&self) -> usize {
481 self.diagnostics
482 .iter()
483 .filter(|d| d.severity == DiagnosticSeverity::Error)
484 .count()
485 }
486
487 pub fn has_errors(&self) -> bool {
488 self.error_count() > 0
489 }
490}
491
492pub fn resolve_scan_path(project_dir: &Path, scan_path: &str) -> PathBuf {
495 crate::core::paths::resolve_project_path(project_dir, scan_path, &Default::default())
496 .unwrap_or_else(|_| project_dir.to_path_buf())
497}
498
499pub use crate::core::paths::is_remote_uri;
500
501fn deserialize_string_or_vec<'de, D>(
503 deserializer: D,
504) -> std::result::Result<Option<Vec<String>>, D::Error>
505where
506 D: serde::Deserializer<'de>,
507{
508 use serde::de::{self, SeqAccess, Visitor};
509 use std::fmt;
510
511 struct StringOrVec;
512 impl<'de> Visitor<'de> for StringOrVec {
513 type Value = Option<Vec<String>>;
514
515 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
516 f.write_str("a string or list of strings")
517 }
518
519 fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
520 Ok(None)
521 }
522
523 fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
524 Ok(None)
525 }
526
527 fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
528 Ok(Some(vec![v.to_string()]))
529 }
530
531 fn visit_string<E: de::Error>(self, v: String) -> std::result::Result<Self::Value, E> {
532 Ok(Some(vec![v]))
533 }
534
535 fn visit_seq<A: SeqAccess<'de>>(
536 self,
537 mut seq: A,
538 ) -> std::result::Result<Self::Value, A::Error> {
539 let mut out = Vec::new();
540 while let Some(s) = seq.next_element::<String>()? {
541 out.push(s);
542 }
543 Ok(Some(out))
544 }
545 }
546
547 deserializer.deserialize_any(StringOrVec)
548}
549
550pub fn scan_path_exists(project_dir: &Path, scan_path: &str) -> bool {
553 scan_path_exists_with_roots(project_dir, scan_path, &std::collections::HashMap::new())
554}
555
556pub fn scan_path_exists_with_roots(
558 project_dir: &Path,
559 scan_path: &str,
560 roots: &std::collections::HashMap<String, String>,
561) -> bool {
562 if is_remote_uri(scan_path.trim()) {
563 return true;
564 }
565 let Ok(resolved) = crate::core::paths::resolve_project_path(project_dir, scan_path, roots)
566 else {
567 return false;
568 };
569 let check = strip_simple_glob(&resolved);
571 check.exists()
572}
573
574fn strip_simple_glob(path: &Path) -> PathBuf {
575 let s = path.to_string_lossy();
576 if s.contains('*') || s.contains('?') {
577 if let Some(parent) = path.parent() {
578 return parent.to_path_buf();
579 }
580 }
581 path.to_path_buf()
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn format_from_extension_and_parse() {
590 assert_eq!(
591 SourceFormat::from_extension("jsonl"),
592 Some(SourceFormat::Jsonl)
593 );
594 assert_eq!(
595 SourceFormat::from_extension("toml"),
596 Some(SourceFormat::Toml)
597 );
598 assert_eq!(SourceFormat::from_extension("log"), Some(SourceFormat::Log));
599 assert_eq!(
600 SourceFormat::parse("arrow-ipc").unwrap(),
601 SourceFormat::ArrowIpc
602 );
603 assert_eq!(SourceFormat::parse("ndjson").unwrap(), SourceFormat::Jsonl);
604 }
605
606 #[test]
607 fn resolve_format_from_path() {
608 let fm = StagingFrontmatter {
609 scan_path: Some("lake/bronze/raw.jsonl".into()),
610 ..Default::default()
611 };
612 assert_eq!(fm.resolve_format().unwrap(), SourceFormat::Jsonl);
613 }
614
615 #[test]
616 fn remote_uri_exists_for_compile() {
617 assert!(scan_path_exists(
618 Path::new("/tmp"),
619 "s3://bucket/bronze/x.jsonl"
620 ));
621 }
622}