rivet/types/source_column.rs
1//! Native database column metadata captured before mapping to a [`RivetType`].
2//!
3//! See `rivet_roadmap.md` §Epic 14 (type safety). §6 — Internal Type System.
4//! point of this struct is to keep *enough* native-DB metadata to make a
5//! lossless mapping decision later — in particular `precision` / `scale` for
6//! `numeric(p,s)` / `decimal(p,s)` — instead of going straight to
7//! `arrow::DataType` and silently degrading to `Utf8`.
8//!
9//! The struct is intentionally driver-agnostic: PostgreSQL fills it in from
10//! `pg_attribute` / `information_schema`, MySQL from `information_schema`,
11//! and the planner can also synthesize a row from a YAML override (roadmap §8).
12
13use serde::Serialize;
14
15use super::RivetType;
16
17/// Metadata captured from the source database for a single column,
18/// before it is mapped to a [`RivetType`].
19///
20/// Names follow the roadmap struct (§6 SourceColumn) one-for-one so the
21/// document and the code stay in lockstep. Optional fields are populated
22/// only when the source driver actually provides them — Rivet must never
23/// invent a precision/scale to "fill in" a missing one (that is exactly
24/// the silent-degradation pattern the roadmap forbids).
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct SourceColumn {
27 /// Column name as reported by the source database.
28 pub name: String,
29 /// Native database type identifier — vendor-specific string used for
30 /// reports and policy decisions (e.g. `"numeric"`, `"timestamptz"`,
31 /// `"jsonb"`). Always present.
32 pub native_type: String,
33 /// True when the source schema declares the column nullable.
34 ///
35 /// **Current limitation (see ADR-0016):** every driver passes `true`
36 /// here unconditionally — Rivet does not query
37 /// `information_schema.columns.is_nullable` (MySQL) /
38 /// `pg_attribute.attnotnull` (PostgreSQL), so source `NOT NULL`
39 /// constraints are *not* propagated to the output Parquet schema.
40 /// Downstream catalog tools therefore see every column as nullable
41 /// regardless of source declaration. Conservative for write paths
42 /// (any value passes), lossy for read paths (cannot distinguish
43 /// "schema-mandated NOT NULL" from "allows NULL but happened to have
44 /// no NULLs in this run"). Tracked for v0.8 Phase A type-report
45 /// extension (per ADR-0014).
46 pub nullable: bool,
47 /// Decimal precision — number of total significant digits. Only present
48 /// for fixed-precision numeric types where the source actually declared
49 /// a precision (e.g. `numeric(18,2)` → `Some(18)`; `numeric` → `None`).
50 pub precision: Option<u8>,
51 /// Decimal scale — number of digits to the right of the decimal point.
52 /// Signed because PostgreSQL allows negative scale for `numeric` (e.g.
53 /// `numeric(5,-2)` rounds to hundreds).
54 pub scale: Option<i8>,
55 /// Length for variable-length character/binary types when the source
56 /// declares one (e.g. `varchar(255)` → `Some(255)`).
57 pub length: Option<u64>,
58 /// Fractional-second precision for temporal types when declared
59 /// (e.g. `timestamp(6)` → `Some(6)`).
60 pub datetime_precision: Option<u8>,
61}
62
63impl SourceColumn {
64 /// Convenience constructor for the most common case: a column whose
65 /// native type doesn't carry length / precision metadata.
66 pub fn simple(name: impl Into<String>, native_type: impl Into<String>, nullable: bool) -> Self {
67 Self {
68 name: name.into(),
69 native_type: native_type.into(),
70 nullable,
71 precision: None,
72 scale: None,
73 length: None,
74 datetime_precision: None,
75 }
76 }
77
78 /// Convenience constructor for declared decimal columns.
79 #[allow(dead_code)]
80 pub fn decimal(
81 name: impl Into<String>,
82 native_type: impl Into<String>,
83 nullable: bool,
84 precision: u8,
85 scale: i8,
86 ) -> Self {
87 Self {
88 name: name.into(),
89 native_type: native_type.into(),
90 nullable,
91 precision: Some(precision),
92 scale: Some(scale),
93 length: None,
94 datetime_precision: None,
95 }
96 }
97}
98
99/// A user-supplied YAML override (`exports[].columns:` in `rivet.yaml`,
100/// roadmap §8) carries one of these per column. Stored separately from
101/// [`SourceColumn`] so it's obvious in code review whether a particular
102/// type came from autodetect or from an explicit override.
103///
104/// Wired into the planning pipeline by Chunk 6.
105#[allow(dead_code)]
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct ColumnOverride {
108 /// Name of the column the override applies to (case-sensitive — matches
109 /// what the source driver returns; mismatch is rejected at config-load).
110 pub name: String,
111 /// The user-declared type. Replaces whatever autodetect produced.
112 pub rivet_type: RivetType,
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn simple_constructor_leaves_optional_metadata_blank() {
121 let c = SourceColumn::simple("id", "int8", false);
122 assert_eq!(c.name, "id");
123 assert_eq!(c.native_type, "int8");
124 assert!(!c.nullable);
125 assert_eq!(c.precision, None);
126 assert_eq!(c.scale, None);
127 assert_eq!(c.length, None);
128 assert_eq!(c.datetime_precision, None);
129 }
130
131 #[test]
132 fn decimal_constructor_sets_precision_scale() {
133 let c = SourceColumn::decimal("amount", "numeric", true, 18, 2);
134 assert_eq!(c.precision, Some(18));
135 assert_eq!(c.scale, Some(2));
136 assert!(c.nullable);
137 }
138
139 #[test]
140 fn negative_scale_is_supported_for_postgres_numeric() {
141 let c = SourceColumn::decimal("rough", "numeric", false, 5, -2);
142 assert_eq!(c.scale, Some(-2));
143 }
144
145 #[test]
146 fn source_column_serializes_nullable_optional_fields() {
147 let c = SourceColumn::decimal("amount", "numeric", true, 18, 2);
148 let json: serde_json::Value =
149 serde_json::from_str(&serde_json::to_string(&c).expect("serialize")).expect("parse");
150 assert_eq!(json["name"], "amount");
151 assert_eq!(json["native_type"], "numeric");
152 assert_eq!(json["nullable"], true);
153 assert_eq!(json["precision"], 18);
154 assert_eq!(json["scale"], 2);
155 assert!(json["length"].is_null());
156 assert!(json["datetime_precision"].is_null());
157 }
158}