use serde_json::{Map, Value};
use sqlx::{Column, Row, TypeInfo, ValueRef};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NumericAs {
#[default]
Number,
String,
}
impl NumericAs {
pub fn parse(s: &str) -> Option<Self> {
match s {
"number" => Some(Self::Number),
"string" => Some(Self::String),
_ => None,
}
}
pub const VALUES: &'static str = "number/string";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BinaryAs {
#[default]
Auto,
Hex,
Base64,
Text,
}
impl BinaryAs {
pub fn parse(s: &str) -> Option<Self> {
match s {
"auto" => Some(Self::Auto),
"hex" => Some(Self::Hex),
"base64" => Some(Self::Base64),
"text" => Some(Self::Text),
_ => None,
}
}
pub const VALUES: &'static str = "auto/hex/base64/text";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RowFormat {
pub numeric: NumericAs,
pub binary: BinaryAs,
}
impl RowFormat {
pub fn new(numeric: NumericAs, binary: BinaryAs) -> Self {
Self { numeric, binary }
}
}
pub struct DecodeError {
pub column: String,
pub sql_type: String,
pub detail: String,
}
impl DecodeError {
pub fn message(&self, function: &str) -> String {
format!(
"{function}: column '{}' has SQL type {}, which cannot be represented as \
JSON here ({}). Cast it in the query — `SELECT {}::text` — and use \
`parse_json` if it holds a document, or select the columns you need.",
self.column, self.sql_type, self.detail, self.column
)
}
}
type Decoded<T> = Result<T, DecodeError>;
fn column_names<R: Row>(row: &R) -> Vec<String> {
row.columns().iter().map(|c| c.name().to_string()).collect()
}
fn decimal_to_json(exact: String, mode: NumericAs, column: &str, sql_type: &str) -> Decoded<Value> {
match mode {
NumericAs::String => Ok(Value::String(exact)),
NumericAs::Number => exact
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.ok_or_else(|| DecodeError {
column: column.to_string(),
sql_type: sql_type.to_string(),
detail: format!("'{exact}' is not a finite JSON number"),
}),
}
}
fn float_to_json(v: f64, column: &str, sql_type: &str) -> Decoded<Value> {
serde_json::Number::from_f64(v)
.map(Value::Number)
.ok_or_else(|| DecodeError {
column: column.to_string(),
sql_type: sql_type.to_string(),
detail: format!("the value is {v}, which JSON cannot represent"),
})
}
fn blob_to_json(bytes: Vec<u8>, mode: BinaryAs, column: &str, sql_type: &str) -> Decoded<Value> {
let hex = |b: &[u8]| Value::String(crate::crypto::encode_bytes(crate::crypto::Codec::Hex, b));
Ok(match mode {
BinaryAs::Auto => match String::from_utf8(bytes) {
Ok(s) => Value::String(s),
Err(e) => hex(&e.into_bytes()),
},
BinaryAs::Hex => hex(&bytes),
BinaryAs::Base64 => Value::String(crate::crypto::encode_bytes(
crate::crypto::Codec::Base64,
&bytes,
)),
BinaryAs::Text => match String::from_utf8(bytes) {
Ok(s) => Value::String(s),
Err(e) => {
return Err(DecodeError {
column: column.to_string(),
sql_type: sql_type.to_string(),
detail: format!("binary_as is \"text\" but the bytes are not valid UTF-8: {e}"),
});
}
},
})
}
pub fn pg_rows_to_json(rows: &[sqlx::postgres::PgRow], format: RowFormat) -> Decoded<Vec<Value>> {
rows.iter()
.map(|row| {
let mut obj = Map::new();
for (i, name) in column_names(row).into_iter().enumerate() {
obj.insert(name.clone(), pg_column(row, i, &name, format)?);
}
Ok(Value::Object(obj))
})
.collect()
}
fn pg_column(
row: &sqlx::postgres::PgRow,
i: usize,
name: &str,
format: RowFormat,
) -> Decoded<Value> {
use sqlx::postgres::{PgTypeInfo, PgTypeKind};
let raw = row.try_get_raw(i).map_err(|e| DecodeError {
column: name.to_string(),
sql_type: "?".to_string(),
detail: e.to_string(),
})?;
if raw.is_null() {
return Ok(Value::Null);
}
let info: PgTypeInfo = raw.type_info().into_owned();
let sql_type = info.name().to_string();
let fail = |detail: String| DecodeError {
column: name.to_string(),
sql_type: sql_type.clone(),
detail,
};
let kind = info.kind().clone();
if let PgTypeKind::Domain(inner) = &kind {
return pg_by_name(row, i, name, inner.name(), format, &fail);
}
if let PgTypeKind::Enum(_) = &kind {
let bytes = raw.as_bytes().map_err(|e| fail(e.to_string()))?;
return std::str::from_utf8(bytes)
.map(|s| Value::String(s.to_string()))
.map_err(|e| fail(format!("enum label is not UTF-8: {e}")));
}
if let PgTypeKind::Array(elem) = &kind {
return pg_array(row, i, name, elem.name(), format, &fail);
}
pg_by_name(row, i, name, &sql_type, format, &fail)
}
fn pg_by_name(
row: &sqlx::postgres::PgRow,
i: usize,
column: &str,
sql_type: &str,
format: RowFormat,
fail: &dyn Fn(String) -> DecodeError,
) -> Decoded<Value> {
macro_rules! get {
($t:ty) => {
row.try_get::<$t, _>(i).map_err(|e| fail(e.to_string()))?
};
}
let value = match sql_type {
"BOOL" => Value::Bool(get!(bool)),
"INT2" => Value::Number(i64::from(get!(i16)).into()),
"INT4" => Value::Number(i64::from(get!(i32)).into()),
"INT8" => Value::Number(get!(i64).into()),
"OID" => Value::Number(u64::from(get!(sqlx::postgres::types::Oid).0).into()),
"FLOAT4" => float_to_json(f64::from(get!(f32)), column, sql_type)?,
"FLOAT8" => float_to_json(get!(f64), column, sql_type)?,
"NUMERIC" => decimal_to_json(
get!(bigdecimal::BigDecimal).to_string(),
format.numeric,
column,
sql_type,
)?,
"TEXT" | "VARCHAR" | "CHAR" | "NAME" | "citext" | "UNKNOWN" => Value::String(get!(String)),
"UUID" => Value::String(get!(uuid::Uuid).to_string()),
"JSON" | "JSONB" => get!(Value),
"BYTEA" => blob_to_json(get!(Vec<u8>), format.binary, column, sql_type)?,
"TIMESTAMPTZ" => Value::String(
get!(chrono::DateTime<chrono::Utc>)
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
),
"TIMESTAMP" => Value::String(get!(chrono::NaiveDateTime).to_string()),
"DATE" => Value::String(get!(chrono::NaiveDate).to_string()),
"TIME" => Value::String(get!(chrono::NaiveTime).to_string()),
other => {
return Err(fail(format!(
"no JSON representation for {other} is defined"
)));
}
};
Ok(value)
}
fn pg_array(
row: &sqlx::postgres::PgRow,
i: usize,
column: &str,
elem: &str,
format: RowFormat,
fail: &dyn Fn(String) -> DecodeError,
) -> Decoded<Value> {
macro_rules! arr {
($t:ty, $f:expr) => {{
let items: Vec<Option<$t>> = row.try_get(i).map_err(|e| fail(e.to_string()))?;
let f = $f;
items
.into_iter()
.map(|v| match v {
Some(v) => f(v),
None => Ok(Value::Null),
})
.collect::<Decoded<Vec<Value>>>()?
}};
}
let items = match elem {
"BOOL" => arr!(bool, |v: bool| Ok(Value::Bool(v))),
"INT2" => arr!(i16, |v: i16| Ok(Value::Number(i64::from(v).into()))),
"INT4" => arr!(i32, |v: i32| Ok(Value::Number(i64::from(v).into()))),
"INT8" => arr!(i64, |v: i64| Ok(Value::Number(v.into()))),
"FLOAT4" => arr!(f32, |v: f32| float_to_json(f64::from(v), column, elem)),
"FLOAT8" => arr!(f64, |v: f64| float_to_json(v, column, elem)),
"NUMERIC" => arr!(bigdecimal::BigDecimal, |v: bigdecimal::BigDecimal| {
decimal_to_json(v.to_string(), format.numeric, column, elem)
}),
"TEXT" | "VARCHAR" | "CHAR" | "NAME" | "citext" => {
arr!(String, |v: String| Ok(Value::String(v)))
}
"OID" => arr!(
sqlx::postgres::types::Oid,
|v: sqlx::postgres::types::Oid| Ok(Value::Number(u64::from(v.0).into()))
),
"UUID" => arr!(uuid::Uuid, |v: uuid::Uuid| Ok(Value::String(v.to_string()))),
"JSON" | "JSONB" => arr!(Value, Ok::<Value, DecodeError>),
"BYTEA" => arr!(Vec<u8>, |v: Vec<u8>| blob_to_json(
v,
format.binary,
column,
elem
)),
"TIMESTAMPTZ" => arr!(chrono::DateTime<chrono::Utc>, |v: chrono::DateTime<
chrono::Utc,
>| {
Ok(Value::String(
v.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
))
}),
"TIMESTAMP" => arr!(chrono::NaiveDateTime, |v: chrono::NaiveDateTime| Ok(
Value::String(v.to_string())
)),
"DATE" => arr!(chrono::NaiveDate, |v: chrono::NaiveDate| Ok(Value::String(
v.to_string()
))),
"TIME" => arr!(chrono::NaiveTime, |v: chrono::NaiveTime| Ok(Value::String(
v.to_string()
))),
other => {
return Err(fail(format!(
"no JSON representation for an array of {other} is defined"
)));
}
};
Ok(Value::Array(items))
}
pub fn mysql_rows_to_json(
rows: &[sqlx::mysql::MySqlRow],
format: RowFormat,
) -> Decoded<Vec<Value>> {
rows.iter()
.map(|row| {
let mut obj = Map::new();
for (i, name) in column_names(row).into_iter().enumerate() {
obj.insert(name.clone(), mysql_column(row, i, &name, format)?);
}
Ok(Value::Object(obj))
})
.collect()
}
fn mysql_column(
row: &sqlx::mysql::MySqlRow,
i: usize,
name: &str,
format: RowFormat,
) -> Decoded<Value> {
let raw = row.try_get_raw(i).map_err(|e| DecodeError {
column: name.to_string(),
sql_type: "?".to_string(),
detail: e.to_string(),
})?;
if raw.is_null() {
return Ok(Value::Null);
}
let sql_type = raw.type_info().name().to_string();
let fail = |detail: String| DecodeError {
column: name.to_string(),
sql_type: sql_type.clone(),
detail,
};
macro_rules! get {
($t:ty) => {
row.try_get::<$t, _>(i).map_err(|e| fail(e.to_string()))?
};
}
let value = match sql_type.as_str() {
"BOOLEAN" => Value::Bool(get!(bool)),
"TINYINT" => Value::Number(i64::from(get!(i8)).into()),
"SMALLINT" => Value::Number(i64::from(get!(i16)).into()),
"YEAR" => Value::Number(u64::from(get!(u16)).into()),
"INT" | "MEDIUMINT" => Value::Number(i64::from(get!(i32)).into()),
"BIGINT" => Value::Number(get!(i64).into()),
"TINYINT UNSIGNED" => Value::Number(u64::from(get!(u8)).into()),
"SMALLINT UNSIGNED" => Value::Number(u64::from(get!(u16)).into()),
"INT UNSIGNED" | "MEDIUMINT UNSIGNED" => Value::Number(u64::from(get!(u32)).into()),
"BIGINT UNSIGNED" => Value::Number(get!(u64).into()),
"FLOAT" => float_to_json(f64::from(get!(f32)), name, &sql_type)?,
"DOUBLE" => float_to_json(get!(f64), name, &sql_type)?,
"DECIMAL" => decimal_to_json(
get!(bigdecimal::BigDecimal).to_string(),
format.numeric,
name,
&sql_type,
)?,
"VARCHAR" | "CHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT" | "ENUM" => {
Value::String(get!(String))
}
"JSON" => get!(Value),
"BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" => {
blob_to_json(get!(Vec<u8>), format.binary, name, &sql_type)?
}
"TIMESTAMP" => Value::String(
get!(chrono::DateTime<chrono::Utc>)
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
),
"DATETIME" => Value::String(get!(chrono::NaiveDateTime).to_string()),
"DATE" => Value::String(get!(chrono::NaiveDate).to_string()),
"TIME" => Value::String(get!(chrono::NaiveTime).to_string()),
other => {
return Err(fail(format!(
"no JSON representation for {other} is defined"
)));
}
};
Ok(value)
}
pub fn sqlite_rows_to_json(
rows: &[sqlx::sqlite::SqliteRow],
format: RowFormat,
) -> Decoded<Vec<Value>> {
rows.iter()
.map(|row| {
let mut obj = Map::new();
for (i, name) in column_names(row).into_iter().enumerate() {
obj.insert(name.clone(), sqlite_column(row, i, &name, format)?);
}
Ok(Value::Object(obj))
})
.collect()
}
fn sqlite_column(
row: &sqlx::sqlite::SqliteRow,
i: usize,
name: &str,
format: RowFormat,
) -> Decoded<Value> {
let raw = row.try_get_raw(i).map_err(|e| DecodeError {
column: name.to_string(),
sql_type: "?".to_string(),
detail: e.to_string(),
})?;
if raw.is_null() {
return Ok(Value::Null);
}
let sql_type = raw.type_info().name().to_string();
let fail = |detail: String| DecodeError {
column: name.to_string(),
sql_type: sql_type.clone(),
detail,
};
macro_rules! get {
($t:ty) => {
row.try_get::<$t, _>(i).map_err(|e| fail(e.to_string()))?
};
}
let value = match sql_type.as_str() {
"INTEGER" => Value::Number(get!(i64).into()),
"REAL" => float_to_json(get!(f64), name, &sql_type)?,
"TEXT" => Value::String(get!(String)),
"BLOB" => blob_to_json(get!(Vec<u8>), format.binary, name, &sql_type)?,
other => {
return Err(fail(format!(
"no JSON representation for {other} is defined"
)));
}
};
Ok(value)
}
#[cfg(test)]
mod binary_tests {
#![allow(clippy::panic)]
use super::*;
fn blob(bytes: Vec<u8>, mode: BinaryAs) -> Value {
match blob_to_json(bytes, mode, "c", "BLOB") {
Ok(v) => v,
Err(e) => panic!("{}", e.message("test")),
}
}
#[test]
fn auto_reads_utf8_as_text_and_the_rest_as_hex() {
assert_eq!(
blob(b"hello".to_vec(), BinaryAs::Auto),
Value::String("hello".to_string())
);
assert_eq!(
blob(vec![0xff, 0x00], BinaryAs::Auto),
Value::String("ff00".to_string())
);
}
#[test]
fn a_named_encoding_does_not_depend_on_the_bytes() {
for (mode, utf8, binary) in [
(BinaryAs::Hex, "68690a", "ff00"),
(BinaryAs::Base64, "aGkK", "/wA="),
] {
assert_eq!(
blob(b"hi\n".to_vec(), mode),
Value::String(utf8.to_string()),
"{mode:?} on text-shaped bytes"
);
assert_eq!(
blob(vec![0xff, 0x00], mode),
Value::String(binary.to_string()),
"{mode:?} on binary bytes"
);
}
}
#[test]
fn text_refuses_bytes_that_are_not_utf8() {
let Err(err) = blob_to_json(vec![0xff], BinaryAs::Text, "payload", "BYTEA") else {
panic!("bytes that are not UTF-8 must not decode as text");
};
let msg = err.message("db_read");
assert!(msg.contains("payload"), "{msg}");
assert!(msg.contains("binary_as"), "{msg}");
}
#[test]
fn the_default_is_the_historical_rule() {
assert_eq!(BinaryAs::default(), BinaryAs::Auto);
assert_eq!(RowFormat::default().binary, BinaryAs::Auto);
assert_eq!(BinaryAs::parse("base64"), Some(BinaryAs::Base64));
assert_eq!(BinaryAs::parse("utf8"), None);
}
}