use faucet_common_clickhouse::ClickHouseConnection;
use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError, validate_batch_size};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClickHouseReplication {
#[default]
Full,
Incremental {
column: String,
initial_value: Value,
},
}
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClickHouseSourceConfig {
#[serde(flatten)]
pub connection: ClickHouseConnection,
pub query: String,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default)]
pub replication: ClickHouseReplication,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_key: Option<String>,
}
impl std::fmt::Debug for ClickHouseSourceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClickHouseSourceConfig")
.field("connection", &self.connection)
.field("query", &self.query)
.field("batch_size", &self.batch_size)
.field("replication", &self.replication)
.field("state_key", &self.state_key)
.finish()
}
}
impl ClickHouseSourceConfig {
pub fn new(url: impl Into<String>, query: impl Into<String>) -> Self {
Self {
connection: ClickHouseConnection::from_url(url),
query: query.into(),
batch_size: default_batch_size(),
replication: ClickHouseReplication::Full,
state_key: None,
}
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn incremental(mut self, column: impl Into<String>, initial: Value) -> Self {
self.replication = ClickHouseReplication::Incremental {
column: column.into(),
initial_value: initial,
};
self
}
pub fn validate(&self) -> Result<(), FaucetError> {
self.connection.validate()?;
validate_batch_size(self.batch_size)?;
if let ClickHouseReplication::Incremental { column, .. } = &self.replication
&& column.trim().is_empty()
{
return Err(FaucetError::Config(
"ClickHouse incremental replication requires a non-empty `column`".into(),
));
}
if self.incremental_without_bookmark_pushdown() {
tracing::warn!(
"ClickHouse incremental replication query has no `@bookmark` token: the \
cursor is applied client-side only, so the server returns the ENTIRE \
result set on every run (correctness is preserved, but it is a full \
re-scan). Add `@bookmark` to the WHERE clause to push the cursor down, \
e.g. `... WHERE {column} > @bookmark`",
column = match &self.replication {
ClickHouseReplication::Incremental { column, .. } => column.as_str(),
_ => "<column>",
}
);
}
Ok(())
}
pub(crate) fn incremental_without_bookmark_pushdown(&self) -> bool {
matches!(self.replication, ClickHouseReplication::Incremental { .. })
&& !self.query.contains("@bookmark")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn config_flattens_connection_fields() {
let cfg: ClickHouseSourceConfig = serde_json::from_value(json!({
"url": "http://localhost:8123",
"database": "analytics",
"query": "SELECT 1",
}))
.unwrap();
assert_eq!(cfg.connection.url.as_deref(), Some("http://localhost:8123"));
assert_eq!(cfg.connection.database, "analytics");
assert_eq!(cfg.batch_size, DEFAULT_BATCH_SIZE);
}
#[test]
fn replication_full_is_default() {
let cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1");
assert_eq!(cfg.replication, ClickHouseReplication::Full);
}
#[test]
fn replication_incremental_parses() {
let r: ClickHouseReplication = serde_json::from_value(json!({
"type": "incremental",
"column": "updated_at",
"initial_value": "1970-01-01",
}))
.unwrap();
assert_eq!(
r,
ClickHouseReplication::Incremental {
column: "updated_at".into(),
initial_value: json!("1970-01-01"),
}
);
}
#[test]
fn validate_rejects_incremental_without_column() {
let cfg = ClickHouseSourceConfig {
replication: ClickHouseReplication::Incremental {
column: " ".into(),
initial_value: json!(0),
},
..ClickHouseSourceConfig::new("http://h:8123", "SELECT 1")
};
assert!(cfg.validate().is_err());
}
#[test]
fn validate_rejects_bad_batch_size() {
let cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1")
.with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
assert!(cfg.validate().is_err());
}
#[test]
fn validate_rejects_missing_endpoint() {
let mut cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1");
cfg.connection.url = None;
assert!(cfg.validate().is_err());
}
#[test]
fn incremental_without_bookmark_pushdown_flags_missing_token() {
let missing = ClickHouseSourceConfig::new("http://h:8123", "SELECT * FROM t")
.incremental("updated_at", json!("1970-01-01"));
assert!(missing.incremental_without_bookmark_pushdown());
assert!(missing.validate().is_ok(), "warn, not hard error");
let with_token = ClickHouseSourceConfig::new(
"http://h:8123",
"SELECT * FROM t WHERE updated_at > @bookmark",
)
.incremental("updated_at", json!("1970-01-01"));
assert!(!with_token.incremental_without_bookmark_pushdown());
let full = ClickHouseSourceConfig::new("http://h:8123", "SELECT * FROM t");
assert!(!full.incremental_without_bookmark_pushdown());
}
#[test]
fn debug_masks_password() {
let mut cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1");
cfg.connection.password = Some("s3cret".into());
let dbg = format!("{cfg:?}");
assert!(dbg.contains("***"));
assert!(!dbg.contains("s3cret"));
}
}