rivet/types/mapping.rs
1//! `RivetType` → `arrow::DataType` + Arrow metadata.
2//!
3//! See `rivet_roadmap.md` §Epic 14. §5 — Type Mapping Pipeline, §14 —
4//! ("Binary, UUID, JSON" — metadata example). This module is intentionally
5//! the *only* place where `RivetType` becomes an `arrow::DataType`. Source
6//! drivers must not poke at `arrow::DataType` directly any more — they
7//! produce a [`SourceColumn`], call into a vendor-specific
8//! `<vendor>_to_rivet()` (Chunks 2/3), then hand the resulting
9//! [`TypeMapping`] to [`build_arrow_field`] here.
10//!
11//! Why funnel everything through one function:
12//!
13//! - It guarantees the metadata key set ([`META_NATIVE_TYPE`],
14//! [`META_LOGICAL_TYPE`], [`META_FIDELITY`]) is identical regardless of
15//! the source database, so downstream consumers (e.g. BigQuery target
16//! check) can rely on it.
17//! - It keeps Arrow as a *target language*, not a public API — Chunks 4–8
18//! add policy and overrides without each one needing to know about
19//! `Field::with_metadata`.
20
21use std::collections::HashMap;
22
23use arrow::datatypes::{DataType, Field, TimeUnit as ArrowTimeUnit};
24use arrow_schema::extension::{Json as ArrowJson, Uuid as ArrowUuid};
25use serde::Serialize;
26use std::sync::Arc;
27
28use super::{RivetType, SourceColumn, TimeUnit, TypeFidelity};
29
30/// Arrow field-metadata key carrying the native database type name.
31/// Read by the type-report CLI (Chunk 5) and by future BigQuery / Snowflake
32/// target checks so they can produce hints like
33/// "this column came from `numeric(18,2)`".
34pub const META_NATIVE_TYPE: &str = "rivet.native_type";
35/// Arrow field-metadata key carrying the Rivet logical type — used for
36/// types whose physical Arrow representation is `Utf8` but whose semantic
37/// type is recoverable (e.g. `json`, `uuid`).
38pub const META_LOGICAL_TYPE: &str = "rivet.logical_type";
39/// Arrow field-metadata key carrying the [`TypeFidelity`] label.
40/// CI / strict-mode tooling can sniff this to assert that no field in a
41/// produced Parquet schema is `lossy` or `unsupported`.
42pub const META_FIDELITY: &str = "rivet.fidelity";
43
44/// One row of the Type Mapping Pipeline (roadmap §6 `TypeMapping`).
45///
46/// Carries the full provenance from a source-DB column to its eventual
47/// Arrow representation. The struct is what the type-report CLI prints
48/// and what `TypePolicy` validates against.
49#[derive(Debug, Clone, Serialize)]
50pub struct TypeMapping {
51 /// Column name (matches `SourceColumn::name`).
52 pub column_name: String,
53 /// Native source-DB type identifier (`numeric(18,2)`, `timestamptz`,
54 /// `jsonb`, …).
55 pub source_native_type: String,
56 /// The canonical Rivet type produced by the vendor mapper.
57 pub rivet_type: RivetType,
58 /// Resolved Arrow type, or `None` for [`RivetType::Unsupported`] until
59 /// a policy turns it into something exportable.
60 ///
61 /// Kept as `arrow::DataType` (not a stringly-typed name) so the
62 /// pipeline can build an `arrow::Schema` directly from a
63 /// `Vec<TypeMapping>`.
64 #[serde(serialize_with = "serialize_arrow_type_opt")]
65 pub arrow_type: Option<DataType>,
66 /// Fidelity classification — see [`TypeFidelity`].
67 pub fidelity: TypeFidelity,
68 /// True when the source schema declares the column nullable. Threaded
69 /// from `SourceColumn::nullable` so [`build_arrow_field`] doesn't need
70 /// the original column.
71 pub nullable: bool,
72 /// Diagnostic strings emitted by the mapper or the policy. Surfaced by
73 /// the type-report and the strict-mode failure message.
74 pub warnings: Vec<String>,
75}
76
77impl TypeMapping {
78 /// Build a mapping from a [`SourceColumn`] and an already-resolved
79 /// [`RivetType`]. The Arrow type is computed by [`rivet_type_to_arrow`]
80 /// and the fidelity by [`derive_fidelity`].
81 ///
82 /// This is the canonical constructor used by every vendor mapper —
83 /// Chunks 2/3 will call this once per source column.
84 pub fn from_source(source: &SourceColumn, rivet_type: RivetType) -> Self {
85 let fidelity = derive_fidelity(&rivet_type);
86 let arrow_type = rivet_type_to_arrow(&rivet_type);
87 Self {
88 column_name: source.name.clone(),
89 source_native_type: source.native_type.clone(),
90 rivet_type,
91 arrow_type,
92 fidelity,
93 nullable: source.nullable,
94 warnings: Vec::new(),
95 }
96 }
97
98 /// Append a warning visible to the type-report and to logs.
99 #[allow(dead_code)]
100 pub fn with_warning(mut self, msg: impl Into<String>) -> Self {
101 self.warnings.push(msg.into());
102 self
103 }
104}
105
106fn serialize_arrow_type_opt<S: serde::Serializer>(
107 v: &Option<DataType>,
108 s: S,
109) -> std::result::Result<S::Ok, S::Error> {
110 match v {
111 None => s.serialize_none(),
112 Some(dt) => s.serialize_some(&format!("{dt:?}")),
113 }
114}
115
116/// Map [`RivetType`] → [`arrow::DataType`].
117///
118/// This is the *only* place where Arrow types are constructed from Rivet
119/// types. Source drivers must not duplicate this logic; they go through
120/// [`TypeMapping::from_source`] instead.
121///
122/// Returns `None` for [`RivetType::Unsupported`] — the policy layer
123/// (Chunk 4) is responsible for either failing the run or rewriting the
124/// `RivetType` into something supported (e.g. `Unsupported -> String`).
125pub fn rivet_type_to_arrow(t: &RivetType) -> Option<DataType> {
126 match t {
127 RivetType::Bool => Some(DataType::Boolean),
128 RivetType::Int16 => Some(DataType::Int16),
129 RivetType::Int32 => Some(DataType::Int32),
130 RivetType::Int64 => Some(DataType::Int64),
131 RivetType::UInt64 => Some(DataType::UInt64),
132 RivetType::Float32 => Some(DataType::Float32),
133 RivetType::Float64 => Some(DataType::Float64),
134 RivetType::Decimal { precision, scale } => Some(decimal_arrow_type(*precision, *scale)),
135 RivetType::Date => Some(DataType::Date32),
136 RivetType::Time { unit } => Some(DataType::Time64(arrow_unit(*unit))),
137 RivetType::Timestamp { unit, timezone } => Some(DataType::Timestamp(
138 arrow_unit(*unit),
139 timezone.as_deref().map(Into::into),
140 )),
141 // UUID: 16-byte canonical binary representation. Paired with the
142 // `arrow.uuid` extension type metadata in `build_arrow_field`, this
143 // lets parquet-rs emit native `LogicalType::Uuid` instead of
144 // `String`. Downstream Parquet readers (DuckDB / ClickHouse /
145 // pyarrow / BigQuery autodetect) recognise UUID natively without
146 // an extra cast.
147 RivetType::Uuid => Some(DataType::FixedSizeBinary(16)),
148
149 // Logical-string types: physical Arrow is Utf8; the metadata
150 // attached by `build_arrow_field` records that the source meant
151 // something more specific (json/enum).
152 RivetType::String | RivetType::Text | RivetType::Json | RivetType::Enum => {
153 Some(DataType::Utf8)
154 }
155
156 RivetType::Binary => Some(DataType::Binary),
157
158 // Interval → Utf8 (ISO 8601 duration string, e.g. "P1Y2M3D").
159 // Arrow's Interval(MonthDayNano) cannot be written to Parquet, so we
160 // serialise to a lossless text representation in the source driver.
161 RivetType::Interval => Some(DataType::Utf8),
162
163 // One-dimensional array: recursively resolve the inner element type.
164 // Returns None if the inner type itself is Unsupported.
165 RivetType::List { inner } => rivet_type_to_arrow(inner)
166 .map(|inner_dt| DataType::List(Arc::new(Field::new("item", inner_dt, true)))),
167
168 RivetType::Unsupported { .. } => None,
169 }
170}
171
172/// Decimal128 vs Decimal256 selection per roadmap §12 ("Exact Decimal Support"):
173/// `Decimal128(p,s)` when `p <= 38`, `Decimal256(p,s)` otherwise.
174///
175/// Negative scale is allowed by PostgreSQL `numeric(p,-s)` and by ARROW, so it is
176/// forwarded through unchanged here. But the PARQUET decimal logical type requires
177/// `scale >= 0`, so a negative-scale column is NOT Parquet-writable — the parquet
178/// writer refuses it up front (see `ParquetFormat::create_writer`) with a clear
179/// error rather than letting `check` pass and `run` fail mid-export. (CSV renders
180/// it fine as text, so the reject is parquet-format-specific, not a type-level one.)
181fn decimal_arrow_type(precision: u8, scale: i8) -> DataType {
182 if precision <= 38 {
183 DataType::Decimal128(precision, scale)
184 } else {
185 DataType::Decimal256(precision, scale)
186 }
187}
188
189fn arrow_unit(u: TimeUnit) -> ArrowTimeUnit {
190 match u {
191 TimeUnit::Second => ArrowTimeUnit::Second,
192 TimeUnit::Millisecond => ArrowTimeUnit::Millisecond,
193 TimeUnit::Microsecond => ArrowTimeUnit::Microsecond,
194 TimeUnit::Nanosecond => ArrowTimeUnit::Nanosecond,
195 }
196}
197
198/// Compute the [`TypeFidelity`] for a freshly-resolved [`RivetType`].
199///
200/// The output of every vendor mapper goes through this so the fidelity
201/// label is computed in *exactly one place* and the type-report stays
202/// consistent across PostgreSQL / MySQL / future drivers.
203pub fn derive_fidelity(t: &RivetType) -> TypeFidelity {
204 match t {
205 RivetType::Bool
206 | RivetType::Int16
207 | RivetType::Int32
208 | RivetType::Int64
209 | RivetType::UInt64
210 | RivetType::Float32
211 | RivetType::Float64
212 | RivetType::Decimal { .. }
213 | RivetType::Date
214 | RivetType::Time { .. }
215 | RivetType::Timestamp { .. }
216 | RivetType::String
217 | RivetType::Text
218 | RivetType::Binary => TypeFidelity::Exact,
219
220 // UUID now exports as canonical `FixedSizeBinary(16)` + the
221 // `arrow.uuid` extension type (parquet-rs emits native
222 // `LogicalType::Uuid`). Both value and physical type match the
223 // canonical Parquet representation — `Exact`. Bumped from
224 // `Compatible` when we still wrote the hyphenated text.
225 RivetType::Uuid => TypeFidelity::Exact,
226
227 // JSON is preserved byte-for-byte but its native semantics
228 // (object/array tree) are not — call it `logical_string`.
229 RivetType::Json => TypeFidelity::LogicalString,
230
231 // Enum labels are text — value preserved, but native enum semantics
232 // (ordered labels, constraint) are not enforced in Arrow.
233 RivetType::Enum => TypeFidelity::Compatible,
234
235 // Interval: Arrow IntervalMonthDayNano preserves all three components
236 // exactly; downstream tools may interpret it differently.
237 RivetType::Interval => TypeFidelity::Compatible,
238
239 // List: Arrow List preserves element values (1-D only currently), but a
240 // list is never *better* than its element — and never worse than
241 // `Compatible` for a supported element. A list of an **Unsupported**
242 // element (e.g. PG `numeric[]`, whose element precision can't be
243 // resolved and has no override — array overrides aren't accepted) is
244 // itself Unsupported: the run can't build the field, so `--strict` and
245 // the type-report must say so too (otherwise `check` reports it safe
246 // while `run` fails — a check↔run inconsistency). A Lossy element drags
247 // the list to Lossy likewise.
248 RivetType::List { inner } => match derive_fidelity(inner) {
249 f @ (TypeFidelity::Unsupported | TypeFidelity::Lossy) => f,
250 _ => TypeFidelity::Compatible,
251 },
252
253 RivetType::Unsupported { .. } => TypeFidelity::Unsupported,
254 }
255}
256
257/// Build an `arrow::Field` from a [`TypeMapping`], attaching the standard
258/// metadata keys ([`META_NATIVE_TYPE`], [`META_LOGICAL_TYPE`],
259/// [`META_FIDELITY`]).
260///
261/// Returns `None` if the mapping has no resolved Arrow type (i.e. the
262/// `RivetType` is `Unsupported` and no policy has rewritten it). Callers
263/// must surface this as a type-policy decision, not as a panic.
264pub fn build_arrow_field(mapping: &TypeMapping) -> Option<Field> {
265 let dt = mapping.arrow_type.clone()?;
266 let mut metadata: HashMap<String, String> = HashMap::new();
267 metadata.insert(META_NATIVE_TYPE.into(), mapping.source_native_type.clone());
268 metadata.insert(META_FIDELITY.into(), mapping.fidelity.label().into());
269 if let Some(logical) = logical_type_label(&mapping.rivet_type) {
270 metadata.insert(META_LOGICAL_TYPE.into(), logical.into());
271 }
272 let mut field = Field::new(&mapping.column_name, dt, mapping.nullable).with_metadata(metadata);
273
274 // Attach the Arrow canonical extension type so that parquet-rs emits the
275 // matching native Parquet `LogicalType` (Uuid / Json) instead of falling
276 // back to `String`. The Rivet-side metadata (`rivet.logical_type=...`)
277 // stays for Rivet-aware consumers; the extension type metadata is what
278 // downstream generic engines (DuckDB, ClickHouse, pyarrow, BigQuery
279 // autodetect) actually read.
280 //
281 // arrow-rs encodes the extension type as the `ARROW:extension:name`
282 // metadata key on the Field; `try_with_extension_type` does the right
283 // thing including any per-extension metadata (Json carries an empty
284 // metadata object today).
285 match mapping.rivet_type {
286 RivetType::Json => {
287 field
288 .try_with_extension_type(ArrowJson::default())
289 .expect("Json extension only valid on Utf8/LargeUtf8 — invariant in mapping");
290 }
291 RivetType::Uuid => {
292 field
293 .try_with_extension_type(ArrowUuid)
294 .expect("Uuid extension only valid on FixedSizeBinary(16) — invariant in mapping");
295 }
296 _ => {}
297 }
298 Some(field)
299}
300
301/// Return the `rivet.logical_type` value for types whose physical Arrow
302/// representation is a generic container (Utf8, Binary) but whose source
303/// semantic is more specific (`json`, `uuid`). `None` when the physical
304/// type already encodes the semantic (e.g. `Decimal128(18,2)` is already
305/// "decimal" in Arrow / Parquet).
306fn logical_type_label(t: &RivetType) -> Option<&'static str> {
307 match t {
308 RivetType::Json => Some("json"),
309 RivetType::Uuid => Some("uuid"),
310 RivetType::Enum => Some("enum"),
311 RivetType::Interval => Some("interval"),
312 _ => None,
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn list_fidelity_propagates_unsupported_element() {
322 // PG `numeric[]`: the element precision can't be resolved and array
323 // overrides aren't accepted → `List<Unsupported>`. A list is never
324 // better than its element, so the whole column is Unsupported —
325 // `--strict` and the type-report must agree with `run`, which fails to
326 // build the field. Regression: this was hardcoded `Compatible`, so
327 // `check --strict` passed while `run` failed (a check↔run gap).
328 let bad = RivetType::List {
329 inner: Box::new(RivetType::Unsupported {
330 native_type: "numeric".into(),
331 reason: "precision unavailable".into(),
332 }),
333 };
334 assert_eq!(derive_fidelity(&bad), TypeFidelity::Unsupported);
335 assert!(bad.is_unsupported());
336
337 // A list of a supported element stays `Compatible` (e.g. `int[]`).
338 let good = RivetType::List {
339 inner: Box::new(RivetType::Int32),
340 };
341 assert_eq!(derive_fidelity(&good), TypeFidelity::Compatible);
342 assert!(!good.is_unsupported());
343 }
344
345 fn col(name: &str, native: &str) -> SourceColumn {
346 SourceColumn::simple(name, native, true)
347 }
348
349 #[test]
350 fn integer_types_map_one_to_one() {
351 for (rt, expected) in [
352 (RivetType::Bool, DataType::Boolean),
353 (RivetType::Int16, DataType::Int16),
354 (RivetType::Int32, DataType::Int32),
355 (RivetType::Int64, DataType::Int64),
356 (RivetType::UInt64, DataType::UInt64),
357 (RivetType::Float32, DataType::Float32),
358 (RivetType::Float64, DataType::Float64),
359 ] {
360 assert_eq!(
361 rivet_type_to_arrow(&rt),
362 Some(expected),
363 "rivet type {rt:?}"
364 );
365 assert_eq!(derive_fidelity(&rt), TypeFidelity::Exact);
366 }
367 }
368
369 #[test]
370 fn decimal_p38_uses_decimal128() {
371 for p in [1u8, 18, 38] {
372 let dt = rivet_type_to_arrow(&RivetType::Decimal {
373 precision: p,
374 scale: 2,
375 })
376 .expect("decimal must map to an Arrow type");
377 assert_eq!(dt, DataType::Decimal128(p, 2), "precision={p}");
378 }
379 }
380
381 /// Roadmap §12: precision >38 must escalate to Decimal256, not silently
382 /// truncate or fall back to Float64. This is the single most important
383 /// invariant of the whole Type Safety Foundation.
384 #[test]
385 fn decimal_above_38_escalates_to_decimal256() {
386 for p in [39u8, 76] {
387 let dt = rivet_type_to_arrow(&RivetType::Decimal {
388 precision: p,
389 scale: 9,
390 })
391 .expect("decimal must map to an Arrow type");
392 assert_eq!(
393 dt,
394 DataType::Decimal256(p, 9),
395 "precision={p} must become Decimal256"
396 );
397 }
398 }
399
400 /// Roadmap §12: PostgreSQL `numeric(5,-2)` rounds to hundreds; the type
401 /// system must round-trip the negative scale.
402 #[test]
403 fn decimal_supports_negative_scale_for_postgres_numeric() {
404 let dt = rivet_type_to_arrow(&RivetType::Decimal {
405 precision: 5,
406 scale: -2,
407 })
408 .expect("decimal must map to an Arrow type");
409 assert_eq!(dt, DataType::Decimal128(5, -2));
410 }
411
412 #[test]
413 fn timestamp_preserves_timezone_semantics() {
414 let naive = RivetType::Timestamp {
415 unit: TimeUnit::Microsecond,
416 timezone: None,
417 };
418 let utc = RivetType::Timestamp {
419 unit: TimeUnit::Microsecond,
420 timezone: Some("UTC".into()),
421 };
422 assert_eq!(
423 rivet_type_to_arrow(&naive),
424 Some(DataType::Timestamp(ArrowTimeUnit::Microsecond, None))
425 );
426 assert_eq!(
427 rivet_type_to_arrow(&utc),
428 Some(DataType::Timestamp(
429 ArrowTimeUnit::Microsecond,
430 Some("UTC".into())
431 ))
432 );
433 }
434
435 #[test]
436 fn unsupported_returns_no_arrow_type() {
437 let t = RivetType::Unsupported {
438 native_type: "interval".into(),
439 reason: "no mapping yet".into(),
440 };
441 assert_eq!(rivet_type_to_arrow(&t), None);
442 assert_eq!(derive_fidelity(&t), TypeFidelity::Unsupported);
443 }
444
445 #[test]
446 fn json_is_logical_string_with_metadata() {
447 let mapping = TypeMapping::from_source(&col("payload", "jsonb"), RivetType::Json);
448 assert_eq!(mapping.fidelity, TypeFidelity::LogicalString);
449 assert_eq!(mapping.arrow_type, Some(DataType::Utf8));
450
451 let field = build_arrow_field(&mapping).expect("field");
452 assert_eq!(field.data_type(), &DataType::Utf8);
453 assert_eq!(
454 field.metadata().get(META_NATIVE_TYPE).map(String::as_str),
455 Some("jsonb")
456 );
457 assert_eq!(
458 field.metadata().get(META_LOGICAL_TYPE).map(String::as_str),
459 Some("json")
460 );
461 assert_eq!(
462 field.metadata().get(META_FIDELITY).map(String::as_str),
463 Some("logical_string")
464 );
465 }
466
467 #[test]
468 fn uuid_is_exact_fixed_size_binary_with_logical_metadata() {
469 // UUID exports as canonical FixedSizeBinary(16) + the `arrow.uuid`
470 // extension; this combo lets parquet-rs emit native
471 // `LogicalType::Uuid` (downstream engines autoload as UUID type).
472 // Fidelity bumped from `compatible` to `exact` to reflect the
473 // canonical Parquet representation.
474 let mapping = TypeMapping::from_source(&col("id", "uuid"), RivetType::Uuid);
475 assert_eq!(mapping.fidelity, TypeFidelity::Exact);
476 assert_eq!(mapping.arrow_type, Some(DataType::FixedSizeBinary(16)));
477
478 let field = build_arrow_field(&mapping).expect("field");
479 assert_eq!(
480 field.metadata().get(META_LOGICAL_TYPE).map(String::as_str),
481 Some("uuid")
482 );
483 assert_eq!(
484 field.metadata().get(META_FIDELITY).map(String::as_str),
485 Some("exact")
486 );
487 // The Arrow extension key drives the Parquet native logical type.
488 assert_eq!(
489 field
490 .metadata()
491 .get("ARROW:extension:name")
492 .map(String::as_str),
493 Some("arrow.uuid")
494 );
495 }
496
497 #[test]
498 fn plain_string_has_no_logical_type_metadata() {
499 let mapping = TypeMapping::from_source(&col("name", "text"), RivetType::String);
500 let field = build_arrow_field(&mapping).expect("field");
501 assert!(
502 !field.metadata().contains_key(META_LOGICAL_TYPE),
503 "plain string columns must NOT carry rivet.logical_type so consumers \
504 can distinguish them from json/uuid columns"
505 );
506 assert_eq!(
507 field.metadata().get(META_NATIVE_TYPE).map(String::as_str),
508 Some("text")
509 );
510 assert_eq!(
511 field.metadata().get(META_FIDELITY).map(String::as_str),
512 Some("exact")
513 );
514 }
515
516 #[test]
517 fn binary_stays_binary_not_string() {
518 // Roadmap §14: binary columns must never be silently exported as Utf8.
519 let mapping = TypeMapping::from_source(&col("payload", "bytea"), RivetType::Binary);
520 let field = build_arrow_field(&mapping).expect("field");
521 assert_eq!(field.data_type(), &DataType::Binary);
522 assert_eq!(mapping.fidelity, TypeFidelity::Exact);
523 }
524
525 #[test]
526 fn unsupported_yields_no_field() {
527 let unsupported = RivetType::Unsupported {
528 native_type: "interval".into(),
529 reason: "no mapping".into(),
530 };
531 let mapping = TypeMapping::from_source(&col("dur", "interval"), unsupported);
532 assert!(
533 build_arrow_field(&mapping).is_none(),
534 "Unsupported must NOT silently produce a Utf8 field — that's exactly the \
535 silent-degradation pattern the roadmap forbids (§5)"
536 );
537 }
538
539 #[test]
540 fn nullable_flag_propagates_from_source_column() {
541 let nullable = SourceColumn::simple("a", "int4", true);
542 let not_nullable = SourceColumn::simple("b", "int4", false);
543 let m_nullable = TypeMapping::from_source(&nullable, RivetType::Int32);
544 let m_required = TypeMapping::from_source(¬_nullable, RivetType::Int32);
545 assert!(build_arrow_field(&m_nullable).expect("f").is_nullable());
546 assert!(!build_arrow_field(&m_required).expect("f").is_nullable());
547 }
548
549 #[test]
550 fn warnings_are_attachable_via_builder() {
551 let mapping = TypeMapping::from_source(&col("x", "int4"), RivetType::Int32)
552 .with_warning("autodetect uncertainty");
553 assert_eq!(mapping.warnings, vec!["autodetect uncertainty".to_string()]);
554 }
555}