clickhouse.rs icon indicating copy to clipboard operation
clickhouse.rs copied to clipboard

Support for `rust_decimal::Decimal` in RowBinaryWithNamesAndTypes Deserialization

Open amaendeepm opened this issue 3 months ago • 2 comments

💡 Support for rust_decimal::Decimal in RowBinaryWithNamesAndTypes Deserialization

Summary

After upgrading from clickhouse = 0.13.3 to 0.14.0, direct deserialization attempt of Decimal columns into rust_decimal::Decimal no longer works.
The driver now throws:

DeserializeAnyNotSupported

Environment

  • clickhouse crate: 0.14.x
  • rust_decimal: 1.x
  • ClickHouse server: 24.x
  • Rust: 1.89

Example Code

#[derive(clickhouse::Row, serde::Deserialize, Debug)]
struct SpotPrice {
    hour_of_day: u8,
    spot_price_eur: rust_decimal::Decimal,
}

let rows = ch_client
    .query("SELECT hour_of_day, spot_price_eur FROM elspotprices LIMIT 5")
    .fetch_all::<SpotPrice>()
    .await?;

If I change the field type to f32, it works correctly, but that loses precision when working with financial or energy pricing data.

amaendeepm avatar Oct 13 '25 11:10 amaendeepm

Perhaps this could be used as a workaround for now:

trait RawDecimalType {}
impl RawDecimalType for i32 {}
impl RawDecimalType for i64 {}
impl RawDecimalType for i128 {}

fn deserialize_rust_decimal<
    'de,
    D,
    T: RawDecimalType + Deserialize<'de> + Into<i128> + Display,
    const SCALE: u32,
>(
    d: D,
) -> Result<Decimal, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = T::deserialize(d)?;
    Decimal::try_from_i128_with_scale(raw.into(), SCALE).map_err(|e| {
        let sz = std::any::type_name::<T>();
        DeserializeError::custom(format!(
            "failed to deserialize Decimal with size {sz} and scale {SCALE}, cause: {e}"
        ))
    })
}

fn serialize_rust_decimal<S, T: Serialize + TryFrom<i128>, const SCALE: u32>(
    val: &Decimal,
    s: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    let mantissa = val.mantissa();
    let raw = T::try_from(mantissa)
        .map_err(|_| S::Error::custom(format!("mantissa does not fit into i32: {mantissa}")))?;
    raw.serialize(s)
}

which then can be used with serialize_with and deserialize_with:

use clickhouse::Row;
use rust_decimal::Decimal;
use serde::de::Error as DeserializeError;
use serde::ser::Error as SerializeError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::Display;

#[tokio::test]
async fn decimals() {
    let table_name = "test";

    #[derive(Clone, Debug, Row, Serialize, Deserialize, PartialEq)]
    struct Data {
        #[serde(
            serialize_with = "serialize_rust_decimal::<_, i32, 4>",
            deserialize_with = "deserialize_rust_decimal::<_, i32, 4>"
        )]
        decimal32_9_4: Decimal,
        #[serde(
            serialize_with = "serialize_rust_decimal::<_, i64, 8>",
            deserialize_with = "deserialize_rust_decimal::<_, i64, 8>"
        )]
        decimal64_18_8: Decimal,
        #[serde(
            serialize_with = "serialize_rust_decimal::<_, i128, 12>",
            deserialize_with = "deserialize_rust_decimal::<_, i128, 12>"
        )]
        decimal128_38_12: Decimal,
    }

    let client = prepare_database!();
    client
        .query(
            "
            CREATE OR REPLACE TABLE {table_name: Identifier} (
                decimal32_9_4    Decimal32(4),
                decimal64_18_8   Decimal64(8),
                decimal128_38_12 Decimal128(12)
            )
            ENGINE = MergeTree
            ORDER BY tuple()
            ",
        )
        .param("table_name", table_name)
        .execute()
        .await
        .unwrap();

    let rows = vec![Data {
        // 42.1234
        decimal32_9_4: Decimal::from_i128_with_scale(421234, 4),
        // 144.56789012
        decimal64_18_8: Decimal::from_i128_with_scale(14456789012, 8),
        // -170141183460469.231731687303
        decimal128_38_12: Decimal::from_i128_with_scale(-170141183460469231731687303, 12),
    }];

    let mut insert = client.insert::<Data>("test").await.unwrap();
    for row in &rows {
        insert.write(row).await.unwrap();
    }
    insert.end().await.unwrap();

    let result = client
        .query("SELECT ?fields FROM {table_name: Identifier} ORDER BY () ASC")
        .param("table_name", table_name)
        .fetch_all::<Data>()
        .await
        .unwrap();

    assert_eq!(result, rows);
}

trait RawDecimalType {}
impl RawDecimalType for i32 {}
impl RawDecimalType for i64 {}
impl RawDecimalType for i128 {}

fn deserialize_rust_decimal<
    'de,
    D,
    T: RawDecimalType + Deserialize<'de> + Into<i128> + Display,
    const SCALE: u32,
>(
    d: D,
) -> Result<Decimal, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = T::deserialize(d)?;
    Decimal::try_from_i128_with_scale(raw.into(), SCALE).map_err(|e| {
        let sz = std::any::type_name::<T>();
        DeserializeError::custom(format!(
            "failed to deserialize Decimal with size {sz} and scale {SCALE}, cause: {e}"
        ))
    })
}

fn serialize_rust_decimal<S, T: Serialize + TryFrom<i128>, const SCALE: u32>(
    val: &Decimal,
    s: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    let mantissa = val.mantissa();
    let raw = T::try_from(mantissa)
        .map_err(|_| S::Error::custom(format!("mantissa does not fit into i32: {mantissa}")))?;
    raw.serialize(s)
}

However, looks like something we could also implement in the scope of #236 under the rust_decimal feature flag.

slvrtrn avatar Oct 15 '25 08:10 slvrtrn

We should be able to support deserialize_any with the info RBWNAT gives us.

abonander avatar Oct 20 '25 14:10 abonander