diff --git a/src/rowbinary/tests.rs b/src/rowbinary/tests.rs index 6ca5b7d3..ffbed747 100644 --- a/src/rowbinary/tests.rs +++ b/src/rowbinary/tests.rs @@ -320,3 +320,132 @@ fn it_time_serializes_time64_nanos_overflow_fails() { "Unexpected error message: {err}" ); } + +// --- IPv4 / IPv6 / FixedString round-trip coverage -------------------------- +// +// The clickhouse-rs spec (`upstream-clickhouse-rs.md` §1.4) describes +// surprising behaviour when round-tripping native `IPv4` / `IPv6` / +// `FixedString(N)` columns through `#[derive(Row)]`. These tests pin the +// expected wire format byte-for-byte so future regressions are caught +// without needing a live ClickHouse server. + +mod ip_and_fixed_string { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use serde::{Deserialize, Serialize}; + + use crate::Row; + + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] + struct IpRow { + // IPv4 must be serialised as a little-endian u32 to match the + // ClickHouse `IPv4` wire format. The default `Ipv4Addr` serde + // produces a 4-tuple of u8 in network order, so this annotation + // is required. + #[serde(with = "crate::serde::ipv4")] + ipv4: Ipv4Addr, + + // IPv6 round-trips correctly via the default `Ipv6Addr` serde. + // `Ipv6Addr::octets()` returns network-order bytes and the + // ClickHouse `IPv6` wire format is also network-order, so the + // shapes match. Including a no-annotation field here pins that + // contract. + ipv6_default: Ipv6Addr, + + // `clickhouse::serde::ipv6` is a pass-through helper provided + // for symmetry; it must produce the same bytes as the default. + #[serde(with = "crate::serde::ipv6")] + ipv6_via_helper: Ipv6Addr, + + // `FixedString(4)` ↔ `[u8; 4]`: exactly N bytes on the wire, + // user is responsible for any padding. + fixed4: [u8; 4], + } + + impl Row for IpRow { + const NAME: &'static str = "IpRow"; + const COLUMN_NAMES: &'static [&'static str] = + &["ipv4", "ipv6_default", "ipv6_via_helper", "fixed4"]; + const COLUMN_COUNT: usize = 4; + const KIND: crate::row::RowKind = crate::row::RowKind::Struct; + type Value<'a> = IpRow; + } + + fn sample() -> IpRow { + IpRow { + ipv4: Ipv4Addr::new(192, 168, 0, 1), + ipv6_default: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0xafc8, 0x10, 0x1), + ipv6_via_helper: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0xafc8, 0x10, 0x1), + fixed4: [b'B', b'T', b'C', 0x00], + } + } + + fn sample_serialised() -> Vec { + let mut bytes = Vec::new(); + // IPv4 192.168.0.1 → u32 0xC0A80001 → LE bytes 01 00 A8 C0 + bytes.extend_from_slice(&u32::from(Ipv4Addr::new(192, 168, 0, 1)).to_le_bytes()); + // IPv6 2001:0db8::afc8:0010:0001 → 16 bytes network order, twice. + let ipv6_octets = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0xafc8, 0x10, 0x1).octets(); + bytes.extend_from_slice(&ipv6_octets); + bytes.extend_from_slice(&ipv6_octets); + // FixedString(4) "BTC\0" + bytes.extend_from_slice(&[b'B', b'T', b'C', 0x00]); + bytes + } + + #[test] + fn ipv4_ipv6_fixedstring_serialise_to_expected_bytes() { + let mut actual = Vec::new(); + crate::rowbinary::serialize_row_binary(&mut actual, &sample()).unwrap(); + assert_eq!( + actual, + sample_serialised(), + "IPv4/IPv6/FixedString wire format diverged from spec" + ); + } + + #[test] + fn ipv4_ipv6_fixedstring_round_trip() { + let bytes = sample_serialised(); + let decoded: IpRow = + crate::rowbinary::deserialize_row(&mut bytes.as_slice(), None).unwrap(); + assert_eq!(decoded, sample(), "round-trip diverged from original"); + } + + #[test] + fn ipv6_helper_matches_default_serde() { + // The whole point of the `serde::ipv6` helper is that it's a + // pass-through. Serialise just the IPv6 fields in two rows + // (one through default, one through helper) and assert byte + // equality so we catch any future divergence. + #[derive(Serialize)] + struct Default(Ipv6Addr); + #[derive(Serialize)] + struct ViaHelper(#[serde(with = "crate::serde::ipv6")] Ipv6Addr); + + impl Row for Default { + const NAME: &'static str = "Default"; + const COLUMN_NAMES: &'static [&'static str] = &["ipv6"]; + const COLUMN_COUNT: usize = 1; + const KIND: crate::row::RowKind = crate::row::RowKind::Struct; + type Value<'a> = Default; + } + impl Row for ViaHelper { + const NAME: &'static str = "ViaHelper"; + const COLUMN_NAMES: &'static [&'static str] = &["ipv6"]; + const COLUMN_COUNT: usize = 1; + const KIND: crate::row::RowKind = crate::row::RowKind::Struct; + type Value<'a> = ViaHelper; + } + + let addr = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0xafc8, 0x10, 0x1); + + let mut a = Vec::new(); + crate::rowbinary::serialize_row_binary(&mut a, &Default(addr)).unwrap(); + + let mut b = Vec::new(); + crate::rowbinary::serialize_row_binary(&mut b, &ViaHelper(addr)).unwrap(); + + assert_eq!(a, b, "ipv6 helper must produce same bytes as default serde"); + } +} diff --git a/src/serde.rs b/src/serde.rs index ae6b9cee..09f2d7d1 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -44,6 +44,33 @@ macro_rules! option { } /// Ser/de [`std::net::Ipv4Addr`] to/from `IPv4`. +/// +/// **This module is required for `Ipv4Addr` fields backed by `IPv4` columns.** +/// `Ipv4Addr`'s default serde implementation produces a 4-element tuple of +/// bytes in network (big-endian) order, but ClickHouse's RowBinary wire +/// format for `IPv4` is a little-endian 32-bit integer. Without this module, +/// schema validation rejects the row with a `SchemaMismatch` error pointing +/// at the column. +/// +/// # Example +/// +/// ```rust,ignore +/// use std::net::Ipv4Addr; +/// use serde::{Deserialize, Serialize}; +/// use clickhouse::Row; +/// +/// #[derive(Row, Serialize, Deserialize)] +/// struct MyRow { +/// #[serde(with = "clickhouse::serde::ipv4")] +/// ipv4: Ipv4Addr, +/// #[serde(with = "clickhouse::serde::ipv4::option")] +/// ipv4_opt: Option, +/// } +/// ``` +/// +/// `Ipv6Addr` does not need an analogous annotation by default; its +/// serde representation already matches the `IPv6` wire format. [`ipv6`] +/// is provided for symmetry and explicit intent. pub mod ipv4 { use std::net::Ipv4Addr; @@ -70,6 +97,62 @@ pub mod ipv4 { } } +/// Ser/de [`std::net::Ipv6Addr`] to/from `IPv6`. +/// +/// `Ipv6Addr` already round-trips correctly without an annotation in the +/// current implementation. Its default serde representation is 16 bytes +/// in network (big-endian) order, which is exactly the RowBinary wire +/// format for `IPv6`. This module is provided so that users can: +/// +/// 1. Mirror the [`ipv4`] pattern for visual consistency in row +/// definitions that mix v4 and v6 columns. +/// 2. Make the IP-address-byte-order contract explicit at the call site. +/// 3. Be insulated from any future wire-format change in ClickHouse. +/// +/// The (de)serialize functions are pass-throughs to `Ipv6Addr`'s default +/// serde implementation; using or omitting this annotation produces the +/// same bytes. Prefer using it explicitly for readability. +/// +/// # Example +/// +/// ```rust,ignore +/// use std::net::Ipv6Addr; +/// use serde::{Deserialize, Serialize}; +/// use clickhouse::Row; +/// +/// #[derive(Row, Serialize, Deserialize)] +/// struct MyRow { +/// #[serde(with = "clickhouse::serde::ipv6")] +/// ipv6: Ipv6Addr, +/// #[serde(with = "clickhouse::serde::ipv6::option")] +/// ipv6_opt: Option, +/// } +/// ``` +pub mod ipv6 { + use std::net::Ipv6Addr; + + use super::*; + + option!( + Ipv6Addr, + "Ser/de `Option` to/from `Nullable(IPv6)`." + ); + + pub fn serialize(ipv6: &Ipv6Addr, serializer: S) -> Result + where + S: Serializer, + { + ipv6.serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ipv6Addr::deserialize(deserializer) + } +} + /// Ser/de [`::uuid::Uuid`] to/from `UUID`. #[cfg(feature = "uuid")] pub mod uuid { diff --git a/tests/it/ip.rs b/tests/it/ip.rs index ff9ddd44..1a60fd1d 100644 --- a/tests/it/ip.rs +++ b/tests/it/ip.rs @@ -54,3 +54,81 @@ async fn smoke() { assert_eq!(row_ipv4_str, original_row.ipv4.to_string()); assert_eq!(row_ipv6_str, original_row.ipv6.to_string()); } + +/// Live ClickHouse round-trip covering IPv4, IPv6, and FixedString(4) in +/// one table. Pins the documented annotation contract: +/// +/// - `Ipv4Addr` requires `#[serde(with = "clickhouse::serde::ipv4")]`. +/// - `Ipv6Addr` round-trips by default; the `ipv6` helper is a +/// pass-through provided for symmetry. +/// - `[u8; N]` maps to `FixedString(N)` byte-for-byte; user supplies any +/// padding. +/// +/// Schema validation is enabled by `default-validation` (the workspace +/// default). The byte-level invariants pinned by the rowbinary unit +/// tests in `src/rowbinary/tests.rs::ip_and_fixed_string` are +/// re-asserted end-to-end here against a real CH instance. +#[tokio::test] +async fn ipv4_ipv6_fixedstring_round_trip() { + let client = prepare_database!(); + + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Row)] + struct MyRow { + #[serde(with = "clickhouse::serde::ipv4")] + ipv4: Ipv4Addr, + ipv6_default: Ipv6Addr, + #[serde(with = "clickhouse::serde::ipv6")] + ipv6_via_helper: Ipv6Addr, + fixed4: [u8; 4], + } + + client + .query( + " + CREATE TABLE test( + ipv4 IPv4, + ipv6_default IPv6, + ipv6_via_helper IPv6, + fixed4 FixedString(4) + ) ENGINE = MergeTree ORDER BY ipv4 + ", + ) + .execute() + .await + .unwrap(); + + let original = MyRow { + ipv4: Ipv4Addr::new(192, 168, 0, 1), + ipv6_default: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0xafc8, 0x10, 0x1), + ipv6_via_helper: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0xafc8, 0x10, 0x1), + fixed4: *b"BTC\0", + }; + + let mut insert = client.insert::("test").await.unwrap(); + insert.write(&original).await.unwrap(); + insert.end().await.unwrap(); + + // Validation enabled (default). Fetch back the row and a server-side + // string view of each column so a CH-side decoding regression + // surfaces here, not just a Rust-side serde regression. + let (row, ipv4_str, ipv6_def_str, ipv6_helper_str, fixed4_str) = client + .query( + "SELECT + ?fields, + toString(ipv4), + toString(ipv6_default), + toString(ipv6_via_helper), + toString(fixed4) + FROM test", + ) + .fetch_one::<(MyRow, String, String, String, String)>() + .await + .unwrap(); + + assert_eq!(row, original, "round-tripped row diverged from input"); + assert_eq!(ipv4_str, original.ipv4.to_string()); + assert_eq!(ipv6_def_str, original.ipv6_default.to_string()); + assert_eq!(ipv6_helper_str, original.ipv6_via_helper.to_string()); + // toString on FixedString preserves user padding bytes. + assert_eq!(fixed4_str.as_bytes(), &original.fixed4); +}