Skip to content

Commit 098c346

Browse files
author
Derek
committed
feat(clickhouse): add ClickHouse client module
New clickhouse feature with: - ArrowClickHouseClient - native Arrow protocol client wrapper - ClickHouseConfig - connection configuration - ParsedType, ColumnInfo, TableSchema - schema types - ClickHouseError - error handling Uses clickhouse-arrow via git dependency from hypersec-io fork. Breaking: Version bump to 0.3.0 for new feature
1 parent bbf4007 commit 098c346

7 files changed

Lines changed: 1155 additions & 3 deletions

File tree

Cargo.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88

99
[package]
1010
name = "hs-rustlib"
11-
version = "0.2.0"
11+
version = "0.3.0"
1212
edition = "2021"
1313
rust-version = "1.75"
1414
description = "Shared utility library for HyperSec Rust applications"
1515
license = "LicenseRef-HyperSec-EULA"
1616
repository = "https://github.com/hypersec-io/hs-rustlib"
1717
publish = ["hypersec"]
18-
keywords = ["config", "logging", "metrics", "prometheus"]
18+
keywords = ["config", "logging", "metrics", "prometheus", "clickhouse"]
1919
categories = ["development-tools"]
2020

2121
[features]
@@ -33,6 +33,9 @@ http = ["reqwest", "reqwest-middleware", "reqwest-retry"]
3333
database = []
3434
cache = []
3535

36+
# ClickHouse support
37+
clickhouse = ["dep:clickhouse-arrow", "dep:arrow", "dep:futures-util", "tokio"]
38+
3639
# Transport features
3740
transport = ["tokio", "async-trait", "serde_json", "rmp-serde", "chrono"]
3841
transport-memory = ["transport"]
@@ -41,7 +44,7 @@ transport-zenoh = ["transport", "zenoh"]
4144
transport-all = ["transport-memory", "transport-kafka", "transport-zenoh"]
4245

4346
# Full feature set
44-
full = ["config", "logger", "metrics", "env", "runtime", "http", "database", "cache", "transport-all"]
47+
full = ["config", "logger", "metrics", "env", "runtime", "http", "database", "cache", "transport-all", "clickhouse"]
4548

4649
[dependencies]
4750
# Serialisation (always needed)
@@ -95,6 +98,11 @@ rdkafka = { version = "0.36", features = ["cmake-build"], optional = true }
9598
# Zenoh transport
9699
zenoh = { version = "1.1", optional = true }
97100

101+
# ClickHouse
102+
clickhouse-arrow = { git = "https://github.com/hypersec-io/clickhouse-arrow", branch = "main", optional = true }
103+
arrow = { version = "57", optional = true }
104+
futures-util = { version = "0.3", default-features = false, features = ["std"], optional = true }
105+
98106
[dev-dependencies]
99107
tokio = { version = "1.0", features = ["full", "test-util"] }
100108
tempfile = "3.10"

src/clickhouse/client.rs

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
// Project: hs-rustlib
2+
// File: src/clickhouse/client.rs
3+
// Purpose: ClickHouse Arrow client wrapper
4+
// Language: Rust
5+
//
6+
// License: LicenseRef-HyperSec-EULA
7+
// Copyright: (c) 2025 HyperSec
8+
9+
//! ClickHouse Arrow client for native protocol operations.
10+
//!
11+
//! Wraps `clickhouse-arrow` with a simplified API for common use cases.
12+
13+
use std::sync::Arc;
14+
15+
use arrow::array::RecordBatch;
16+
use arrow::datatypes::{DataType, SchemaRef};
17+
use clickhouse_arrow::{ArrowFormat, Client, ClientBuilder};
18+
use futures_util::StreamExt;
19+
use futures_util::TryStreamExt;
20+
21+
use super::config::ClickHouseConfig;
22+
use super::error::ClickHouseError;
23+
use super::types::{ColumnInfo, ParsedType, TableSchema};
24+
use super::Result;
25+
26+
/// Type alias for the underlying Arrow client.
27+
pub type ArrowClient = Client<ArrowFormat>;
28+
29+
/// ClickHouse client using native Arrow protocol.
30+
///
31+
/// Provides efficient columnar data transfer for inserts and queries.
32+
/// Uses the native ClickHouse protocol (port 9000) with Arrow format.
33+
///
34+
/// ## Example
35+
///
36+
/// ```rust,no_run
37+
/// use hs_rustlib::clickhouse::{ArrowClickHouseClient, ClickHouseConfig};
38+
///
39+
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
40+
/// let config = ClickHouseConfig::new("localhost:9000", "default");
41+
/// let client = ArrowClickHouseClient::new(&config).await?;
42+
///
43+
/// // Run a query
44+
/// let batches = client.select("SELECT 1 as x").await?;
45+
/// # Ok(())
46+
/// # }
47+
/// ```
48+
pub struct ArrowClickHouseClient {
49+
client: ArrowClient,
50+
database: String,
51+
}
52+
53+
impl ArrowClickHouseClient {
54+
/// Create a new Arrow client from config.
55+
///
56+
/// # Errors
57+
///
58+
/// Returns an error if connection fails.
59+
pub async fn new(config: &ClickHouseConfig) -> Result<Self> {
60+
let host = config.primary_host().ok_or_else(|| {
61+
ClickHouseError::Connection("No ClickHouse hosts configured".into())
62+
})?;
63+
64+
// Parse host:port
65+
let addr = if host.contains(':') {
66+
host.to_string()
67+
} else {
68+
format!("{host}:9000")
69+
};
70+
71+
let client = ClientBuilder::new()
72+
.with_endpoint(&addr)
73+
.with_username(&config.username)
74+
.with_password(&config.password)
75+
.with_database(&config.database)
76+
.build_arrow()
77+
.await
78+
.map_err(|e| ClickHouseError::Connection(format!("Arrow client connect failed: {e}")))?;
79+
80+
Ok(Self {
81+
client,
82+
database: config.database.clone(),
83+
})
84+
}
85+
86+
/// Get the database name.
87+
#[must_use]
88+
pub fn database(&self) -> &str {
89+
&self.database
90+
}
91+
92+
/// Insert an Arrow `RecordBatch` into a table.
93+
///
94+
/// The table can be specified as "db.table" or just "table" (uses default database).
95+
///
96+
/// # Errors
97+
///
98+
/// Returns an error if the insert fails.
99+
pub async fn insert(&self, table: &str, batch: RecordBatch) -> Result<usize> {
100+
if batch.num_rows() == 0 {
101+
return Ok(0);
102+
}
103+
104+
let row_count = batch.num_rows();
105+
let (db, tbl) = parse_db_table(table, &self.database);
106+
let insert_query = format!("INSERT INTO {db}.{tbl} VALUES");
107+
108+
let mut stream = self.client
109+
.insert(&insert_query, batch, None)
110+
.await
111+
.map_err(|e| ClickHouseError::Insert(format!("Arrow insert failed: {e}")))?;
112+
113+
// Consume the stream to complete the insert
114+
while let Some(result) = stream.next().await {
115+
result.map_err(|e| ClickHouseError::Insert(format!("Arrow insert stream error: {e}")))?;
116+
}
117+
118+
Ok(row_count)
119+
}
120+
121+
/// Insert multiple Arrow `RecordBatch`es into a table.
122+
///
123+
/// # Errors
124+
///
125+
/// Returns an error if any insert fails.
126+
pub async fn insert_many(&self, table: &str, batches: Vec<RecordBatch>) -> Result<usize> {
127+
if batches.is_empty() {
128+
return Ok(0);
129+
}
130+
131+
let total_rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
132+
let (db, tbl) = parse_db_table(table, &self.database);
133+
let insert_query = format!("INSERT INTO {db}.{tbl} VALUES");
134+
135+
let mut stream = self.client
136+
.insert_many(&insert_query, batches, None)
137+
.await
138+
.map_err(|e| ClickHouseError::Insert(format!("Arrow insert_many failed: {e}")))?;
139+
140+
while let Some(result) = stream.next().await {
141+
result.map_err(|e| ClickHouseError::Insert(format!("Arrow insert_many stream error: {e}")))?;
142+
}
143+
144+
Ok(total_rows)
145+
}
146+
147+
/// Fetch table schema as Arrow schema.
148+
///
149+
/// # Errors
150+
///
151+
/// Returns an error if the table doesn't exist or schema fetch fails.
152+
pub async fn fetch_schema(&self, table: &str) -> Result<SchemaRef> {
153+
let (db, tbl) = parse_db_table(table, &self.database);
154+
155+
let schemas = self.client
156+
.fetch_schema(Some(&db), &[tbl.as_str()], None)
157+
.await
158+
.map_err(|e| ClickHouseError::Schema(format!("Failed to fetch schema: {e}")))?;
159+
160+
schemas.get(&tbl)
161+
.cloned()
162+
.ok_or_else(|| ClickHouseError::Schema(format!("Table '{table}' not found")))
163+
}
164+
165+
/// Execute a query (for DDL, schema queries, etc.).
166+
///
167+
/// # Errors
168+
///
169+
/// Returns an error if the query fails.
170+
pub async fn query(&self, sql: &str) -> Result<()> {
171+
self.client
172+
.execute(sql, None)
173+
.await
174+
.map_err(|e| ClickHouseError::Query(format!("Query failed: {e}")))
175+
}
176+
177+
/// Execute a SELECT query and return Arrow `RecordBatch`es.
178+
///
179+
/// # Errors
180+
///
181+
/// Returns an error if the query fails.
182+
pub async fn select(&self, sql: &str) -> Result<Vec<RecordBatch>> {
183+
let response = self.client
184+
.query(sql, None)
185+
.await
186+
.map_err(|e| ClickHouseError::Query(format!("SELECT query failed: {e}")))?;
187+
188+
let batches: Vec<RecordBatch> = response
189+
.try_collect()
190+
.await
191+
.map_err(|e| ClickHouseError::Query(format!("Failed to collect query results: {e}")))?;
192+
193+
Ok(batches)
194+
}
195+
196+
/// Check connection health.
197+
///
198+
/// # Errors
199+
///
200+
/// Returns an error if the health check fails.
201+
pub async fn health_check(&self) -> Result<()> {
202+
self.client
203+
.health_check(true)
204+
.await
205+
.map_err(|e| ClickHouseError::Connection(format!("Health check failed: {e}")))
206+
}
207+
208+
/// Check if a table exists.
209+
///
210+
/// # Errors
211+
///
212+
/// Returns an error if the check fails (other than table not found).
213+
pub async fn table_exists(&self, table: &str) -> Result<bool> {
214+
match self.fetch_schema(table).await {
215+
Ok(_) => Ok(true),
216+
Err(ClickHouseError::Schema(_)) => Ok(false),
217+
Err(e) => Err(e),
218+
}
219+
}
220+
221+
/// Get list of all table names in the database.
222+
///
223+
/// # Errors
224+
///
225+
/// Returns an error if the query fails.
226+
pub async fn list_tables(&self) -> Result<Vec<String>> {
227+
let schemas = self.client
228+
.fetch_schema(Some(&self.database), &[], None)
229+
.await
230+
.map_err(|e| ClickHouseError::Schema(format!("Failed to list tables: {e}")))?;
231+
232+
Ok(schemas.keys().cloned().collect())
233+
}
234+
235+
/// Fetch table schema as `TableSchema` (includes parsed types).
236+
///
237+
/// # Errors
238+
///
239+
/// Returns an error if the schema fetch fails.
240+
pub async fn fetch_table_schema(&self, table: &str) -> Result<TableSchema> {
241+
let (db, tbl) = parse_db_table(table, &self.database);
242+
let arrow_schema = self.fetch_schema(table).await?;
243+
244+
let columns: Vec<ColumnInfo> = arrow_schema
245+
.fields()
246+
.iter()
247+
.enumerate()
248+
.map(|(i, field)| {
249+
let type_name = arrow_type_to_ch_name(field.data_type());
250+
ColumnInfo {
251+
name: field.name().clone(),
252+
type_name: type_name.clone(),
253+
parsed_type: ParsedType::parse(&type_name),
254+
position: (i as u64) + 1,
255+
default_kind: String::new(),
256+
default_expression: String::new(),
257+
comment: String::new(),
258+
is_in_primary_key: false,
259+
is_in_sorting_key: false,
260+
}
261+
})
262+
.collect();
263+
264+
Ok(TableSchema {
265+
database: db,
266+
table: tbl,
267+
columns,
268+
comment: String::new(),
269+
})
270+
}
271+
272+
/// Get the underlying Arrow client for advanced operations.
273+
#[must_use]
274+
pub fn inner(&self) -> &ArrowClient {
275+
&self.client
276+
}
277+
}
278+
279+
/// Convert Arrow `DataType` to ClickHouse type name (best effort).
280+
fn arrow_type_to_ch_name(dt: &DataType) -> String {
281+
match dt {
282+
DataType::Int8 => "Int8".to_string(),
283+
DataType::Int16 => "Int16".to_string(),
284+
DataType::Int32 => "Int32".to_string(),
285+
DataType::Int64 => "Int64".to_string(),
286+
DataType::UInt8 => "UInt8".to_string(),
287+
DataType::UInt16 => "UInt16".to_string(),
288+
DataType::UInt32 => "UInt32".to_string(),
289+
DataType::UInt64 => "UInt64".to_string(),
290+
DataType::Float32 => "Float32".to_string(),
291+
DataType::Float64 => "Float64".to_string(),
292+
DataType::Boolean => "Bool".to_string(),
293+
DataType::Utf8 | DataType::LargeUtf8 => "String".to_string(),
294+
DataType::Binary | DataType::LargeBinary => "String".to_string(),
295+
DataType::Date32 | DataType::Date64 => "Date".to_string(),
296+
DataType::FixedSizeBinary(16) => "UUID".to_string(),
297+
DataType::FixedSizeBinary(4) => "IPv4".to_string(),
298+
DataType::FixedSizeBinary(n) => format!("FixedString({n})"),
299+
DataType::List(inner) => format!("Array({})", arrow_type_to_ch_name(inner.data_type())),
300+
DataType::Timestamp(_, _) => "DateTime64(3)".to_string(),
301+
DataType::Time32(_) => "DateTime".to_string(),
302+
DataType::Time64(_) => "DateTime64(6)".to_string(),
303+
_ => "String".to_string(), // Default fallback
304+
}
305+
}
306+
307+
/// Parse "db.table" format, falling back to default database.
308+
fn parse_db_table(table: &str, default_db: &str) -> (String, String) {
309+
if let Some((db, tbl)) = table.split_once('.') {
310+
(db.to_string(), tbl.to_string())
311+
} else {
312+
(default_db.to_string(), table.to_string())
313+
}
314+
}
315+
316+
/// Thread-safe reference to Arrow ClickHouse client.
317+
pub type SharedArrowClient = Arc<ArrowClickHouseClient>;
318+
319+
#[cfg(test)]
320+
mod tests {
321+
use super::*;
322+
323+
#[test]
324+
fn test_parse_db_table() {
325+
let (db, tbl) = parse_db_table("mydb.events", "default");
326+
assert_eq!(db, "mydb");
327+
assert_eq!(tbl, "events");
328+
329+
let (db, tbl) = parse_db_table("events", "default");
330+
assert_eq!(db, "default");
331+
assert_eq!(tbl, "events");
332+
}
333+
334+
#[test]
335+
fn test_arrow_type_to_ch_name() {
336+
assert_eq!(arrow_type_to_ch_name(&DataType::Int64), "Int64");
337+
assert_eq!(arrow_type_to_ch_name(&DataType::Utf8), "String");
338+
assert_eq!(arrow_type_to_ch_name(&DataType::Boolean), "Bool");
339+
assert_eq!(arrow_type_to_ch_name(&DataType::FixedSizeBinary(16)), "UUID");
340+
assert_eq!(arrow_type_to_ch_name(&DataType::FixedSizeBinary(4)), "IPv4");
341+
}
342+
}

0 commit comments

Comments
 (0)