Skip to main content

faucet_common_spanner/
types.rs

1//! Spanner `INFORMATION_SCHEMA` type-string parsing and the mapping from
2//! Spanner column types to JSON-Schema fragments.
3
4use serde_json::{Value, json};
5
6/// A parsed Spanner column type (GoogleSQL dialect).
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum SpannerType {
9    Bool,
10    Int64,
11    Float32,
12    Float64,
13    Timestamp,
14    Date,
15    /// `STRING(n)` / `STRING(MAX)` — the length bound is not retained.
16    String,
17    /// `BYTES(n)` / `BYTES(MAX)` — values travel base64-encoded.
18    Bytes,
19    /// Arbitrary-precision decimal; decoded/encoded as a string to preserve
20    /// precision.
21    Numeric,
22    Json,
23    Array(Box<SpannerType>),
24    /// STRUCT / PROTO / ENUM / anything unrecognized — decoded generically,
25    /// not writable by the sink.
26    Other,
27}
28
29/// Parse an `INFORMATION_SCHEMA.COLUMNS.SPANNER_TYPE` string
30/// (e.g. `STRING(MAX)`, `INT64`, `ARRAY<FLOAT64>`) into a [`SpannerType`].
31/// Unrecognized types parse as [`SpannerType::Other`], never an error.
32pub fn parse_spanner_type(raw: &str) -> SpannerType {
33    let s = raw.trim();
34    let upper = s.to_ascii_uppercase();
35    if let Some(inner) = upper
36        .strip_prefix("ARRAY<")
37        .and_then(|rest| rest.strip_suffix('>'))
38    {
39        return SpannerType::Array(Box::new(parse_spanner_type(inner)));
40    }
41    // Strip a length parameter: `STRING(MAX)` / `BYTES(256)`.
42    let base = upper.split('(').next().unwrap_or(&upper).trim();
43    match base {
44        "BOOL" => SpannerType::Bool,
45        "INT64" => SpannerType::Int64,
46        "FLOAT32" => SpannerType::Float32,
47        "FLOAT64" => SpannerType::Float64,
48        "TIMESTAMP" => SpannerType::Timestamp,
49        "DATE" => SpannerType::Date,
50        "STRING" => SpannerType::String,
51        "BYTES" => SpannerType::Bytes,
52        "NUMERIC" => SpannerType::Numeric,
53        "JSON" => SpannerType::Json,
54        _ => SpannerType::Other,
55    }
56}
57
58/// Map a [`SpannerType`] to the JSON-Schema fragment faucet's discover /
59/// schema-drift machinery expects (an `infer_schema`-shaped `{"type": …}`).
60/// `nullable` widens the type with `"null"`.
61///
62/// NUMERIC maps to `string` (matching the decoder, which emits NUMERIC as a
63/// string to preserve precision).
64pub fn spanner_type_to_json_schema(ty: &SpannerType, nullable: bool) -> Value {
65    let base: Value = match ty {
66        SpannerType::Bool => json!({"type": "boolean"}),
67        SpannerType::Int64 => json!({"type": "integer"}),
68        SpannerType::Float32 | SpannerType::Float64 => json!({"type": "number"}),
69        SpannerType::Json => json!({"type": "object"}),
70        SpannerType::Array(inner) => {
71            json!({"type": "array", "items": spanner_type_to_json_schema(inner, false)})
72        }
73        SpannerType::Timestamp
74        | SpannerType::Date
75        | SpannerType::String
76        | SpannerType::Bytes
77        | SpannerType::Numeric => json!({"type": "string"}),
78        SpannerType::Other => json!({"type": "object"}),
79    };
80    if nullable {
81        let mut obj = base;
82        if let Some(t) = obj.get("type").cloned() {
83            obj["type"] = json!([t, "null"]);
84        }
85        obj
86    } else {
87        base
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn parses_scalar_types_case_insensitively_and_strips_lengths() {
97        assert_eq!(parse_spanner_type("INT64"), SpannerType::Int64);
98        assert_eq!(parse_spanner_type("string(MAX)"), SpannerType::String);
99        assert_eq!(parse_spanner_type("BYTES(256)"), SpannerType::Bytes);
100        assert_eq!(parse_spanner_type("Float64"), SpannerType::Float64);
101        assert_eq!(parse_spanner_type("FLOAT32"), SpannerType::Float32);
102        assert_eq!(parse_spanner_type("NUMERIC"), SpannerType::Numeric);
103        assert_eq!(parse_spanner_type("JSON"), SpannerType::Json);
104        assert_eq!(parse_spanner_type("TIMESTAMP"), SpannerType::Timestamp);
105        assert_eq!(parse_spanner_type("DATE"), SpannerType::Date);
106        assert_eq!(parse_spanner_type("BOOL"), SpannerType::Bool);
107    }
108
109    #[test]
110    fn parses_arrays_recursively() {
111        assert_eq!(
112            parse_spanner_type("ARRAY<INT64>"),
113            SpannerType::Array(Box::new(SpannerType::Int64))
114        );
115        assert_eq!(
116            parse_spanner_type("ARRAY<STRING(MAX)>"),
117            SpannerType::Array(Box::new(SpannerType::String))
118        );
119    }
120
121    #[test]
122    fn unknown_types_map_to_other_not_error() {
123        assert_eq!(
124            parse_spanner_type("STRUCT<a INT64, b STRING(MAX)>"),
125            SpannerType::Other
126        );
127        assert_eq!(parse_spanner_type("PROTO<x.Y>"), SpannerType::Other);
128        assert_eq!(parse_spanner_type(""), SpannerType::Other);
129    }
130
131    #[test]
132    fn json_schema_mapping_and_nullability() {
133        assert_eq!(
134            spanner_type_to_json_schema(&SpannerType::Int64, false),
135            serde_json::json!({"type": "integer"})
136        );
137        assert_eq!(
138            spanner_type_to_json_schema(&SpannerType::Numeric, false),
139            serde_json::json!({"type": "string"})
140        );
141        assert_eq!(
142            spanner_type_to_json_schema(&SpannerType::Bool, true),
143            serde_json::json!({"type": ["boolean", "null"]})
144        );
145        assert_eq!(
146            spanner_type_to_json_schema(&SpannerType::Array(Box::new(SpannerType::Float64)), false),
147            serde_json::json!({"type": "array", "items": {"type": "number"}})
148        );
149    }
150}