Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/rowbinary/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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");
}
}
83 changes: 83 additions & 0 deletions src/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ipv4Addr>,
/// }
/// ```
///
/// `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;

Expand All @@ -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<Ipv6Addr>,
/// }
/// ```
pub mod ipv6 {
use std::net::Ipv6Addr;

use super::*;

option!(
Ipv6Addr,
"Ser/de `Option<Ipv6Addr>` to/from `Nullable(IPv6)`."
);

pub fn serialize<S>(ipv6: &Ipv6Addr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
ipv6.serialize(serializer)
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<Ipv6Addr, D::Error>
where
D: Deserializer<'de>,
{
Ipv6Addr::deserialize(deserializer)
}
}

/// Ser/de [`::uuid::Uuid`] to/from `UUID`.
#[cfg(feature = "uuid")]
pub mod uuid {
Expand Down
78 changes: 78 additions & 0 deletions tests/it/ip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<MyRow>("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);
}
Loading