Skip to main content

rivet/types/
mod.rs

1//! Rivet's internal type system.
2//!
3//! See `rivet_roadmap.md` §Epic 14 (Warehouse Load Layer). North Star —
4//! *"No silent type degradation"* — is enforced architecturally by
5//! routing every source-column type through the pipeline:
6//!
7//! ```text
8//! Source Native Type
9//!     ↓
10//! SourceColumn  ← what the driver knows about the column
11//!     ↓
12//! RivetType     ← canonical, vendor-independent type
13//!     ↓
14//! TypePolicy    ← strict / lossy / unsupported decisions  (Chunk 4)
15//!     ↓
16//! Arrow DataType + Field metadata  ← physical export type
17//! ```
18//!
19//! This module owns the first three boxes. The fourth (Arrow) is built by
20//! [`mapping::build_arrow_field`]; the fifth (TypePolicy) lands in Chunk 4
21//! of the type-safety milestones (see roadmap §18).
22//!
23//! ## Layer
24//!
25//! Layer-classification (ADR-0003): this module is **planning-layer** — it
26//! only describes / classifies types. It must not perform I/O, log
27//! metrics, or hold any pipeline state. Vendor mappers live in
28//! `crate::source::*` and call into this module.
29
30mod cursor;
31pub mod decimal;
32mod fidelity;
33mod mapping;
34mod override_type;
35pub mod policy;
36mod rivet_type;
37mod source_column;
38pub mod target;
39
40pub use cursor::CursorState;
41pub use fidelity::TypeFidelity;
42// Public surface for contract/integration tests; not referenced from the binary.
43#[allow(unused_imports)]
44pub use mapping::{TypeMapping, build_arrow_field, derive_fidelity, rivet_type_to_arrow};
45pub use override_type::parse_type_str;
46pub use rivet_type::{RivetType, TimeUnit};
47pub use source_column::SourceColumn;
48// ColumnOverride is the planned public API for column type overrides (Chunk 6).
49#[allow(unused_imports)]
50pub use source_column::ColumnOverride;
51
52/// Per-export column type overrides: column name → declared [`RivetType`].
53///
54/// Built at plan time from the `columns:` map in `rivet.yaml` (roadmap §8).
55/// Passed to [`crate::source::Source::export`] so drivers can use the
56/// declared precision/scale instead of autodetected (often unavailable) metadata.
57pub type ColumnOverrides = std::collections::HashMap<String, RivetType>;
58
59/// Narrow a parsed override map to ONE table: bare keys (`amount`) apply to
60/// every table; qualified keys (`orders.amount`) apply only to their table and
61/// WIN over a bare key for the same column. Foreign-qualified keys are
62/// excluded entirely. `table` is the bare table name (no schema part).
63/// This is what makes `columns:` safe on a schema-wide multi-table export —
64/// one table's override can never bleed into a same-named column elsewhere.
65pub fn overrides_for_table(all: &ColumnOverrides, table: &str) -> ColumnOverrides {
66    let mut out: ColumnOverrides = ColumnOverrides::new();
67    // Bare keys first…
68    for (k, v) in all {
69        if !k.contains('.') {
70            out.insert(k.clone(), v.clone());
71        }
72    }
73    // …then this table's qualified keys, overwriting.
74    for (k, v) in all {
75        if let Some((t, col)) = k.split_once('.')
76            && t == table
77        {
78            out.insert(col.to_string(), v.clone());
79        }
80    }
81    out
82}
83
84/// The override precedence shared by every source engine: a `columns:` override
85/// wins; otherwise fall back to the engine's autodetected type. Keeping it in
86/// one place is why PostgreSQL and MySQL resolution can't drift on precedence —
87/// each driver supplies only its own `autodetect` closure.
88pub fn resolve_or(
89    overrides: &ColumnOverrides,
90    column: &str,
91    autodetect: impl FnOnce() -> RivetType,
92) -> RivetType {
93    overrides.get(column).cloned().unwrap_or_else(autodetect)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn overrides_for_table_bare_applies_qualified_wins_foreign_excluded() {
102        let mut all = ColumnOverrides::new();
103        all.insert("v".into(), RivetType::Text);
104        all.insert("orders.v".into(), RivetType::Int64);
105        all.insert("ghost.v".into(), RivetType::Bool);
106
107        let orders = overrides_for_table(&all, "orders");
108        assert_eq!(orders.get("v"), Some(&RivetType::Int64), "qualified wins");
109        let users = overrides_for_table(&all, "users");
110        assert_eq!(users.get("v"), Some(&RivetType::Text), "bare fallback");
111        assert_eq!(users.len(), 1, "foreign-qualified keys never leak");
112    }
113
114    #[test]
115    fn resolve_or_prefers_override_then_autodetect() {
116        let mut ov = ColumnOverrides::new();
117        ov.insert(
118            "amount".into(),
119            RivetType::Decimal {
120                precision: 18,
121                scale: 2,
122            },
123        );
124        // Override wins, autodetect not even called.
125        assert_eq!(
126            resolve_or(&ov, "amount", || panic!(
127                "autodetect must not run when overridden"
128            )),
129            RivetType::Decimal {
130                precision: 18,
131                scale: 2
132            }
133        );
134        // No override → autodetect.
135        assert_eq!(
136            resolve_or(&ov, "other", || RivetType::Int64),
137            RivetType::Int64
138        );
139    }
140    use crate::types::mapping::{META_FIDELITY, META_LOGICAL_TYPE, META_NATIVE_TYPE};
141    use arrow::datatypes::DataType;
142
143    /// Top-level smoke test: feeding a typical PostgreSQL `payments` table
144    /// through `SourceColumn → RivetType → Arrow Field` produces the schema
145    /// shape demanded by the roadmap's §20 "Definition of Done":
146    ///
147    /// ```text
148    /// id          bigint          int64            Int64                  exact
149    /// amount      numeric(18,2)   decimal(18,2)    Decimal128(18,2)       exact
150    /// created_at  timestamptz     timestamp_tz     Timestamp(us, UTC)     exact
151    /// payload     jsonb           json             Utf8 + metadata        logical_string
152    /// ```
153    #[test]
154    fn end_to_end_payments_schema_matches_definition_of_done() {
155        let cols: Vec<(SourceColumn, RivetType)> = vec![
156            (
157                SourceColumn::simple("id", "bigint", false),
158                RivetType::Int64,
159            ),
160            (
161                SourceColumn::decimal("amount", "numeric", false, 18, 2),
162                RivetType::Decimal {
163                    precision: 18,
164                    scale: 2,
165                },
166            ),
167            (
168                SourceColumn::simple("created_at", "timestamptz", false),
169                RivetType::Timestamp {
170                    unit: TimeUnit::Microsecond,
171                    timezone: Some("UTC".into()),
172                },
173            ),
174            (
175                SourceColumn::simple("payload", "jsonb", true),
176                RivetType::Json,
177            ),
178        ];
179
180        let mappings: Vec<TypeMapping> = cols
181            .into_iter()
182            .map(|(s, t)| TypeMapping::from_source(&s, t))
183            .collect();
184
185        // Fidelity matrix mirrors the table in the Definition of Done.
186        assert_eq!(mappings[0].fidelity, TypeFidelity::Exact);
187        assert_eq!(mappings[1].fidelity, TypeFidelity::Exact);
188        assert_eq!(mappings[2].fidelity, TypeFidelity::Exact);
189        assert_eq!(mappings[3].fidelity, TypeFidelity::LogicalString);
190
191        // Arrow types are exactly what the roadmap demands — no Utf8 fallback for decimal.
192        assert_eq!(mappings[0].arrow_type, Some(DataType::Int64));
193        assert_eq!(mappings[1].arrow_type, Some(DataType::Decimal128(18, 2)));
194        assert!(matches!(
195            mappings[2].arrow_type,
196            Some(DataType::Timestamp(_, Some(_)))
197        ));
198        assert_eq!(mappings[3].arrow_type, Some(DataType::Utf8));
199
200        // Field-level metadata is preserved end-to-end.
201        let amount_field = build_arrow_field(&mappings[1]).expect("amount");
202        assert_eq!(
203            amount_field
204                .metadata()
205                .get(META_NATIVE_TYPE)
206                .map(String::as_str),
207            Some("numeric")
208        );
209        assert_eq!(
210            amount_field
211                .metadata()
212                .get(META_FIDELITY)
213                .map(String::as_str),
214            Some("exact")
215        );
216
217        let payload_field = build_arrow_field(&mappings[3]).expect("payload");
218        assert_eq!(
219            payload_field
220                .metadata()
221                .get(META_LOGICAL_TYPE)
222                .map(String::as_str),
223            Some("json")
224        );
225    }
226
227    /// Keep `rivet_type_to_arrow` / `derive_fidelity` re-exports live for
228    /// `tests/type_roundtrip` contract tests and downstream tooling.
229    #[test]
230    fn mapping_helpers_reexported_for_contract_tests() {
231        let dec = RivetType::Decimal {
232            precision: 18,
233            scale: 2,
234        };
235        assert!(matches!(
236            rivet_type_to_arrow(&dec),
237            Some(DataType::Decimal128(18, 2))
238        ));
239        assert_eq!(derive_fidelity(&dec), TypeFidelity::Exact);
240    }
241}