use serde::Deserialize;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Deserialize)]
pub struct ColumnMeta {
pub name: String,
#[serde(rename = "type")]
pub ty: String,
#[serde(default)]
pub scale: i64,
}
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, col.scale));
}
Value::Object(obj)
}
fn cell_to_json(cell: Option<&Value>, ty: &str, scale: i64) -> 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, scale),
"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, scale: i64) -> Value {
let trimmed = s.trim();
if scale > 0 || is_fractional_decimal(trimmed) {
if is_finite_decimal(trimmed) {
return Value::String(s.to_owned());
}
return parse_real(s);
}
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 = trimmed.strip_prefix(['+', '-']).unwrap_or(trimmed);
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
return Value::String(s.to_owned());
}
parse_real(s)
}
fn is_fractional_decimal(s: &str) -> bool {
is_finite_decimal(s) && (s.contains('.') || s.contains(['e', 'E']))
}
fn is_finite_decimal(s: &str) -> bool {
let body = s.strip_prefix(['+', '-']).unwrap_or(s);
if body.is_empty() {
return false;
}
let (mantissa, exponent) = match body.split_once(['e', 'E']) {
Some((m, e)) => (m, Some(e)),
None => (body, None),
};
let mut seen_dot = false;
let mut seen_digit = false;
for b in mantissa.bytes() {
match b {
b'0'..=b'9' => seen_digit = true,
b'.' if !seen_dot => seen_dot = true,
_ => return false,
}
}
if !seen_digit {
return false;
}
if let Some(exp) = exponent {
let exp_digits = exp.strip_prefix(['+', '-']).unwrap_or(exp);
if exp_digits.is_empty() || !exp_digits.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
}
true
}
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(),
scale: 0,
}
}
fn col_scaled(name: &str, scale: i64) -> ColumnMeta {
ColumnMeta {
name: name.into(),
ty: "fixed".into(),
scale,
}
}
#[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_fractional_preserves_exact_decimal_as_string() {
let row = [json!("2.5")];
let cols = [col_scaled("RATIO", 1)];
assert_eq!(row_to_json(&row, &cols), json!({"RATIO": "2.5"}));
}
#[test]
fn fixed_high_precision_decimal_preserves_all_digits() {
let row = [json!("12345678901234567890.123456789")];
let cols = [col_scaled("AMOUNT", 9)];
let out = row_to_json(&row, &cols);
assert_eq!(out, json!({"AMOUNT": "12345678901234567890.123456789"}));
assert_eq!(
serde_json::to_string(&out).unwrap(),
r#"{"AMOUNT":"12345678901234567890.123456789"}"#
);
}
#[test]
fn fixed_monetary_amount_preserves_exact_value() {
let row = [json!("1234.56")];
let cols = [col_scaled("PRICE", 2)];
let out = row_to_json(&row, &cols);
assert_eq!(out, json!({"PRICE": "1234.56"}));
assert_eq!(
serde_json::to_string(&out).unwrap(),
r#"{"PRICE":"1234.56"}"#
);
}
#[test]
fn fixed_negative_fractional_preserves_exact_value() {
let row = [json!("-0.0000000001")];
let cols = [col_scaled("DELTA", 10)];
assert_eq!(row_to_json(&row, &cols), json!({"DELTA": "-0.0000000001"}));
}
#[test]
fn fixed_scale_zero_integer_stays_json_integer() {
let row = [json!("100"), json!("-7")];
let cols = [col_scaled("A", 0), col_scaled("B", 0)];
assert_eq!(row_to_json(&row, &cols), json!({"A": 100, "B": -7}));
let out = row_to_json(&row, &cols);
assert_eq!(serde_json::to_string(&out).unwrap(), r#"{"A":100,"B":-7}"#);
}
#[test]
fn fixed_fractional_detected_without_scale_metadata() {
let row = [json!("3.141592653589793238462643383279")];
let cols = [col("PI", "fixed")]; assert_eq!(
row_to_json(&row, &cols),
json!({"PI": "3.141592653589793238462643383279"})
);
}
#[test]
fn fixed_scientific_notation_preserved_as_string() {
let row = [json!("1.5e10")];
let cols = [col_scaled("X", 4)];
assert_eq!(row_to_json(&row, &cols), json!({"X": "1.5e10"}));
}
#[test]
fn fixed_scale_present_in_column_metadata_deserializes() {
let meta: ColumnMeta =
serde_json::from_value(json!({"name": "AMT", "type": "fixed", "scale": 2})).unwrap();
assert_eq!(meta.scale, 2);
let meta0: ColumnMeta =
serde_json::from_value(json!({"name": "ID", "type": "fixed"})).unwrap();
assert_eq!(meta0.scale, 0);
}
#[test]
fn fixed_non_decimal_token_falls_back_to_real() {
let row = [json!("NaN")];
let cols = [col_scaled("X", 2)];
assert_eq!(row_to_json(&row, &cols), json!({"X": "NaN"}));
}
#[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"}));
}
}