use serde::Deserialize;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Deserialize)]
pub struct ColumnMeta {
pub name: String,
#[serde(rename = "type")]
pub ty: String,
}
pub fn row_to_json(row: &[Value], columns: &[ColumnMeta]) -> Value {
let mut obj = Map::with_capacity(columns.len());
for (i, col) in columns.iter().enumerate() {
let cell = row.get(i);
obj.insert(col.name.clone(), cell_to_json(cell, &col.ty));
}
Value::Object(obj)
}
fn cell_to_json(cell: Option<&Value>, ty: &str) -> Value {
let Some(cell) = cell else {
return Value::Null;
};
if cell.is_null() {
return Value::Null;
}
let s = match cell {
Value::String(s) => s.as_str(),
other => return other.clone(),
};
match ty.to_ascii_lowercase().as_str() {
"fixed" => parse_number(s),
"real" => parse_real(s),
"boolean" => parse_bool(s),
"variant" | "object" | "array" => {
serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_owned()))
}
_ => Value::String(s.to_owned()),
}
}
fn parse_number(s: &str) -> Value {
if let Ok(i) = s.parse::<i64>() {
return Value::Number(i.into());
}
if let Ok(u) = s.parse::<u64>() {
return Value::Number(u.into());
}
let digits = s.trim().strip_prefix(['+', '-']).unwrap_or(s.trim());
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
return Value::String(s.to_owned());
}
parse_real(s)
}
fn parse_real(s: &str) -> Value {
match s.parse::<f64>() {
Ok(f) => serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or_else(|| Value::String(s.to_owned())),
Err(_) => Value::String(s.to_owned()),
}
}
fn parse_bool(s: &str) -> Value {
match s {
"true" | "TRUE" | "1" => Value::Bool(true),
"false" | "FALSE" | "0" => Value::Bool(false),
other => Value::String(other.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn col(name: &str, ty: &str) -> ColumnMeta {
ColumnMeta {
name: name.into(),
ty: ty.into(),
}
}
#[test]
fn fixed_parses_as_integer() {
let row = [json!("42")];
let cols = [col("ID", "fixed")];
assert_eq!(row_to_json(&row, &cols), json!({"ID": 42}));
}
#[test]
fn fixed_decimal_falls_back_to_float() {
let row = [json!("2.5")];
let cols = [col("RATIO", "fixed")];
assert_eq!(row_to_json(&row, &cols), json!({"RATIO": 2.5}));
}
#[test]
fn fixed_past_i64_within_u64_stays_numeric() {
let row = [json!("18446744073709551615")]; let cols = [col("ID", "fixed")];
assert_eq!(
row_to_json(&row, &cols),
json!({"ID": 18446744073709551615u64})
);
}
#[test]
fn fixed_beyond_u64_kept_as_string_lossless() {
let row = [json!("123456789012345678901234567890")];
let cols = [col("ID", "fixed")];
assert_eq!(
row_to_json(&row, &cols),
json!({"ID": "123456789012345678901234567890"})
);
}
#[test]
fn real_parses_as_float() {
let row = [json!("0.5")];
let cols = [col("X", "real")];
assert_eq!(row_to_json(&row, &cols), json!({"X": 0.5}));
}
#[test]
fn boolean_parses_lowercase() {
let row = [json!("true"), json!("false")];
let cols = [col("A", "boolean"), col("B", "boolean")];
assert_eq!(row_to_json(&row, &cols), json!({"A": true, "B": false}));
}
#[test]
fn variant_parses_inner_json() {
let row = [json!(r#"{"k":1}"#)];
let cols = [col("DATA", "variant")];
assert_eq!(row_to_json(&row, &cols), json!({"DATA": {"k": 1}}));
}
#[test]
fn variant_falls_back_to_string_on_invalid_json() {
let row = [json!("not-json")];
let cols = [col("DATA", "variant")];
assert_eq!(row_to_json(&row, &cols), json!({"DATA": "not-json"}));
}
#[test]
fn text_passes_through() {
let row = [json!("hello")];
let cols = [col("NAME", "text")];
assert_eq!(row_to_json(&row, &cols), json!({"NAME": "hello"}));
}
#[test]
fn null_cell_maps_to_null() {
let row = [json!(null)];
let cols = [col("X", "fixed")];
assert_eq!(row_to_json(&row, &cols), json!({"X": null}));
}
#[test]
fn missing_trailing_cell_maps_to_null() {
let row: [Value; 0] = [];
let cols = [col("X", "text")];
assert_eq!(row_to_json(&row, &cols), json!({"X": null}));
}
#[test]
fn timestamp_passes_through_as_string() {
let row = [json!("1700000000.000000000")];
let cols = [col("TS", "timestamp_ntz")];
assert_eq!(
row_to_json(&row, &cols),
json!({"TS": "1700000000.000000000"})
);
}
#[test]
fn unknown_type_passes_through_as_string() {
let row = [json!("0xDEADBEEF")];
let cols = [col("BLOB", "binary")];
assert_eq!(row_to_json(&row, &cols), json!({"BLOB": "0xDEADBEEF"}));
}
#[test]
fn non_finite_real_falls_back_to_string() {
let row = [json!("NaN")];
let cols = [col("X", "real")];
assert_eq!(row_to_json(&row, &cols), json!({"X": "NaN"}));
}
}