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 pub nullable: bool,
35 /// Decimal precision — number of total significant digits. Only present
36 /// for fixed-precision numeric types where the source actually declared
37 /// a precision (e.g. `numeric(18,2)` → `Some(18)`; `numeric` → `None`).
38 pub precision: Option<u8>,
39 /// Decimal scale — number of digits to the right of the decimal point.
40 /// Signed because PostgreSQL allows negative scale for `numeric` (e.g.
41 /// `numeric(5,-2)` rounds to hundreds).
42 pub scale: Option<i8>,
43 /// Length for variable-length character/binary types when the source
44 /// declares one (e.g. `varchar(255)` → `Some(255)`).
45 pub length: Option<u64>,
46 /// Fractional-second precision for temporal types when declared
47 /// (e.g. `timestamp(6)` → `Some(6)`).
48 pub datetime_precision: Option<u8>,
49}
50
51impl SourceColumn {
52 /// Convenience constructor for the most common case: a column whose
53 /// native type doesn't carry length / precision metadata.
54 pub fn simple(name: impl Into<String>, native_type: impl Into<String>, nullable: bool) -> Self {
55 Self {
56 name: name.into(),
57 native_type: native_type.into(),
58 nullable,
59 precision: None,
60 scale: None,
61 length: None,
62 datetime_precision: None,
63 }
64 }
65
66 /// Convenience constructor for declared decimal columns.
67 #[allow(dead_code)]
68 pub fn decimal(
69 name: impl Into<String>,
70 native_type: impl Into<String>,
71 nullable: bool,
72 precision: u8,
73 scale: i8,
74 ) -> Self {
75 Self {
76 name: name.into(),
77 native_type: native_type.into(),
78 nullable,
79 precision: Some(precision),
80 scale: Some(scale),
81 length: None,
82 datetime_precision: None,
83 }
84 }
85}
86
87/// A user-supplied YAML override (`exports[].columns:` in `rivet.yaml`,
88/// roadmap §8) carries one of these per column. Stored separately from
89/// [`SourceColumn`] so it's obvious in code review whether a particular
90/// type came from autodetect or from an explicit override.
91///
92/// Wired into the planning pipeline by Chunk 6.
93#[allow(dead_code)]
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct ColumnOverride {
96 /// Name of the column the override applies to (case-sensitive — matches
97 /// what the source driver returns; mismatch is rejected at config-load).
98 pub name: String,
99 /// The user-declared type. Replaces whatever autodetect produced.
100 pub rivet_type: RivetType,
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn simple_constructor_leaves_optional_metadata_blank() {
109 let c = SourceColumn::simple("id", "int8", false);
110 assert_eq!(c.name, "id");
111 assert_eq!(c.native_type, "int8");
112 assert!(!c.nullable);
113 assert_eq!(c.precision, None);
114 assert_eq!(c.scale, None);
115 assert_eq!(c.length, None);
116 assert_eq!(c.datetime_precision, None);
117 }
118
119 #[test]
120 fn decimal_constructor_sets_precision_scale() {
121 let c = SourceColumn::decimal("amount", "numeric", true, 18, 2);
122 assert_eq!(c.precision, Some(18));
123 assert_eq!(c.scale, Some(2));
124 assert!(c.nullable);
125 }
126
127 #[test]
128 fn negative_scale_is_supported_for_postgres_numeric() {
129 let c = SourceColumn::decimal("rough", "numeric", false, 5, -2);
130 assert_eq!(c.scale, Some(-2));
131 }
132
133 #[test]
134 fn source_column_serializes_nullable_optional_fields() {
135 let c = SourceColumn::decimal("amount", "numeric", true, 18, 2);
136 let json: serde_json::Value =
137 serde_json::from_str(&serde_json::to_string(&c).expect("serialize")).expect("parse");
138 assert_eq!(json["name"], "amount");
139 assert_eq!(json["native_type"], "numeric");
140 assert_eq!(json["nullable"], true);
141 assert_eq!(json["precision"], 18);
142 assert_eq!(json["scale"], 2);
143 assert!(json["length"].is_null());
144 assert!(json["datetime_precision"].is_null());
145 }
146}