Skip to main content

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}