use base64::Engine as _;
use faucet_core::FaucetError;
use mysql_async::Value;
use mysql_async::binlog::row::BinlogRow;
use mysql_async::binlog::value::BinlogValue;
use serde_json::{Map, Value as Json};
pub fn value_to_json(v: &Value) -> Json {
match v {
Value::NULL => Json::Null,
Value::Int(i) => Json::from(*i),
Value::UInt(u) => Json::from(*u),
Value::Float(f) => serde_json::Number::from_f64(f64::from(*f))
.map(Json::Number)
.unwrap_or(Json::Null),
Value::Double(d) => serde_json::Number::from_f64(*d)
.map(Json::Number)
.unwrap_or(Json::Null),
Value::Bytes(b) => match std::str::from_utf8(b) {
Ok(s) => Json::String(s.to_owned()),
Err(_) => Json::String(base64::engine::general_purpose::STANDARD.encode(b)),
},
Value::Date(y, mo, d, h, mi, s, micro) => Json::String(format!(
"{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}.{micro:06}"
)),
Value::Time(neg, days, h, mi, s, micro) => {
let sign = if *neg { "-" } else { "" };
let total_hours = u64::from(*days) * 24 + u64::from(*h);
Json::String(format!("{sign}{total_hours:02}:{mi:02}:{s:02}.{micro:06}"))
}
}
}
pub fn binlog_value_to_json(v: &BinlogValue<'_>) -> Result<Json, FaucetError> {
match v {
BinlogValue::Value(val) => Ok(value_to_json(val)),
BinlogValue::Jsonb(jsonb_val) => {
jsonb_value_to_json(jsonb_val.clone().into_owned())
}
BinlogValue::JsonDiff(_) => Err(FaucetError::Source(
"mysql-cdc: received a partial JSON diff (BinlogValue::JsonDiff) for a JSON \
column; this happens under binlog_row_value_options=PARTIAL_JSON and cannot \
be reconstructed without the prior document. Set binlog_row_value_options='' \
(full JSON) on the MySQL server for CDC."
.into(),
)),
}
}
fn jsonb_value_to_json(
jsonb_val: mysql_async::binlog::jsonb::Value<'static>,
) -> Result<Json, FaucetError> {
match jsonb_val.parse() {
Ok(dom) => Ok(Json::from(dom)),
Err(e) => Err(FaucetError::Source(format!(
"mysql-cdc: failed to decode a JSON (JSONB) column value: {e}; the binlog \
bytes for this column are malformed and cannot be reconstructed"
))),
}
}
pub fn binlog_row_to_json(row: &BinlogRow) -> Result<Json, FaucetError> {
let cols = row.columns_ref();
let mut obj = Map::with_capacity(cols.len());
for (i, col) in cols.iter().enumerate() {
let name = {
let n = col.name_str();
if n.is_empty() {
format!("col_{i}")
} else {
n.into_owned()
}
};
let val = match row.as_ref(i) {
Some(bv) => binlog_value_to_json(bv)?,
None => Json::Null,
};
obj.insert(name, val);
}
Ok(Json::Object(obj))
}
#[cfg(test)]
mod tests {
use super::*;
use mysql_async::Value;
#[test]
fn scalars() {
assert_eq!(value_to_json(&Value::NULL), Json::Null);
assert_eq!(value_to_json(&Value::Int(-5)), Json::from(-5));
assert_eq!(value_to_json(&Value::UInt(7)), Json::from(7u64));
assert_eq!(
value_to_json(&Value::Bytes(b"hello".to_vec())),
Json::String("hello".into())
);
}
#[test]
fn double() {
assert_eq!(value_to_json(&Value::Double(1.5)), Json::from(1.5));
}
#[test]
fn date_formats() {
let v = value_to_json(&Value::Date(2026, 6, 6, 12, 30, 0, 0));
assert_eq!(v, Json::String("2026-06-06 12:30:00.000000".into()));
}
#[test]
fn time_positive() {
let v = value_to_json(&Value::Time(false, 0, 1, 30, 0, 0));
assert_eq!(v, Json::String("01:30:00.000000".into()));
}
#[test]
fn time_negative() {
let v = value_to_json(&Value::Time(true, 0, 2, 0, 0, 500_000));
assert_eq!(v, Json::String("-02:00:00.500000".into()));
}
#[test]
fn non_utf8_bytes_base64() {
let v = value_to_json(&Value::Bytes(vec![0xff, 0xfe]));
assert!(matches!(v, Json::String(_)));
if let Json::String(s) = v {
base64::engine::general_purpose::STANDARD
.decode(&s)
.expect("should be valid base64");
}
}
#[test]
fn time_with_days_accumulates_hours() {
let v = value_to_json(&Value::Time(false, 1, 2, 3, 4, 0));
assert_eq!(v, Json::String("26:03:04.000000".into()));
}
#[test]
fn float_nan_is_null() {
let v = value_to_json(&Value::Float(f32::NAN));
assert_eq!(v, Json::Null);
}
#[test]
fn double_infinity_is_null() {
assert_eq!(value_to_json(&Value::Double(f64::INFINITY)), Json::Null);
assert_eq!(value_to_json(&Value::Double(f64::NEG_INFINITY)), Json::Null);
}
#[test]
fn binlog_value_plain_delegates_ok() {
let v = binlog_value_to_json(&BinlogValue::Value(Value::Int(42)))
.expect("plain value converts");
assert_eq!(v, Json::from(42));
}
#[test]
fn binlog_value_json_diff_is_typed_error_not_placeholder() {
let bv = BinlogValue::JsonDiff(Vec::new());
let err = binlog_value_to_json(&bv).expect_err("JsonDiff must error");
assert!(
matches!(err, FaucetError::Source(_)),
"expected FaucetError::Source, got {err:?}"
);
let msg = err.to_string();
assert!(msg.contains("partial JSON diff"), "{msg}");
assert!(msg.contains("PARTIAL_JSON"), "{msg}");
assert!(!msg.contains("<JsonDiff>"), "{msg}");
}
#[test]
fn binlog_row_with_json_diff_propagates_error() {
let bv = BinlogValue::JsonDiff(Vec::new());
assert!(binlog_value_to_json(&bv).is_err());
}
use mysql_async::binlog::jsonb::{JsonbString, OpaqueValue, Value as JsonbValue};
use mysql_async::consts::ColumnType;
#[test]
fn jsonb_opaque_decimal_is_preserved_not_null() {
let data = vec![0x02, 0x01, 0x81, 0x05];
let opaque = JsonbValue::Opaque(OpaqueValue::new(ColumnType::MYSQL_TYPE_NEWDECIMAL, data));
let bv = BinlogValue::Jsonb(opaque);
let got = binlog_value_to_json(&bv).expect("opaque decimal must convert, not error");
assert_ne!(
got,
Json::Null,
"opaque DECIMAL was dropped to null (F23 regression)"
);
assert_eq!(
got,
Json::String("1.5".into()),
"decimal not preserved losslessly"
);
}
#[test]
fn jsonb_opaque_date_is_preserved_not_null() {
let ym: i64 = 2026 * 13 + 6;
let ymd: i64 = (ym << 5) | 22;
let ymdhms: i64 = ymd << 17;
let packed: i64 = ymdhms << 24;
let data = packed.to_le_bytes().to_vec();
let opaque = JsonbValue::Opaque(OpaqueValue::new(ColumnType::MYSQL_TYPE_DATE, data));
let bv = BinlogValue::Jsonb(opaque);
let got = binlog_value_to_json(&bv).expect("opaque date must convert, not error");
assert_ne!(
got,
Json::Null,
"opaque DATE was dropped to null (F23 regression)"
);
match got {
Json::String(s) => {
assert!(s.contains("2026"), "date year not preserved: {s}");
assert!(s.contains("06"), "date month not preserved: {s}");
assert!(s.contains("22"), "date day not preserved: {s}");
}
other => panic!("expected a preserved date string, got {other:?}"),
}
}
#[test]
fn jsonb_opaque_other_type_is_preserved_as_base64_string_not_null() {
let data = vec![0xDE, 0xAD, 0xBE, 0xEF];
let opaque = JsonbValue::Opaque(OpaqueValue::new(ColumnType::MYSQL_TYPE_GEOMETRY, data));
let bv = BinlogValue::Jsonb(opaque);
let got = binlog_value_to_json(&bv).expect("opaque geometry must convert, not error");
assert_ne!(
got,
Json::Null,
"opaque GEOMETRY was dropped to null (F23 regression)"
);
match got {
Json::String(s) => {
assert!(
s.starts_with("base64:"),
"expected base64 opaque encoding, got {s}"
);
}
other => panic!("expected a preserved opaque string, got {other:?}"),
}
}
#[test]
fn jsonb_ordinary_string_still_converts_losslessly() {
let v = JsonbValue::String(JsonbString::new(b"hello".to_vec()));
let bv = BinlogValue::Jsonb(v);
let got = binlog_value_to_json(&bv).expect("ordinary jsonb string converts");
assert_eq!(got, Json::String("hello".into()));
}
#[test]
fn jsonb_ordinary_integer_still_converts_losslessly() {
let bv = BinlogValue::Jsonb(JsonbValue::I64(-42));
let got = binlog_value_to_json(&bv).expect("ordinary jsonb int converts");
assert_eq!(got, Json::from(-42));
}
#[test]
fn jsonb_object_with_opaque_field_preserves_whole_document() {
let opaque = JsonbValue::Opaque(OpaqueValue::new(
ColumnType::MYSQL_TYPE_GEOMETRY,
vec![0x01, 0x02],
));
let got = jsonb_value_to_json(opaque).expect("opaque scalar converts");
assert_ne!(got, Json::Null);
}
}