1pub(crate) mod probe;
24pub(crate) mod typeparse;
25
26use crate::config::SchemaValidation;
27use crate::writer::ClickHouseEndpoint;
28use probe::{FieldShape, Shape, compatible};
29use serde::Deserialize;
30use std::fmt::Write as _;
31use std::sync::Arc;
32use typeparse::ChType;
33
34#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum SchemaError {
38 #[error("sink.clickhouse: could not fetch schema for {table} from {url}: {reason}")]
43 Fetch {
44 table: String,
46 url: String,
48 reason: String,
50 },
51 #[error("{0}")]
54 Mismatch(String),
55}
56
57#[derive(Debug)]
62pub struct RowSchema {
63 pub(crate) mode: SchemaValidation,
64 pub(crate) table: String,
65 pub(crate) columns: Vec<(String, ChType, String)>,
67}
68
69#[derive(Clone, Debug)]
71pub(crate) struct SchemaCheck {
72 pub(crate) mode: SchemaValidation,
73 pub(crate) database: Option<String>,
74 pub(crate) table: String,
75 pub(crate) columns: Vec<String>,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, clickhouse::Row, Deserialize)]
80pub(crate) struct ColumnRow {
81 pub(crate) name: String,
82 #[serde(rename = "type")]
83 pub(crate) type_: String,
84 pub(crate) default_kind: String,
85}
86
87impl SchemaCheck {
88 fn target(&self) -> (Option<&str>, &str) {
91 match self.table.split_once('.') {
92 Some((db, tbl)) => (Some(db), tbl),
93 None => (self.database.as_deref(), &self.table),
94 }
95 }
96
97 fn display_table(&self) -> String {
98 match self.target() {
99 (Some(db), tbl) => format!("`{db}`.`{tbl}`"),
100 (None, tbl) => format!("`{tbl}`"),
101 }
102 }
103}
104
105async fn fetch_columns(
107 endpoint: &ClickHouseEndpoint,
108 check: &SchemaCheck,
109) -> Result<Vec<ColumnRow>, SchemaError> {
110 let (db, table) = check.target();
111 let query = match db {
112 Some(db) => endpoint
113 .client()
114 .query(
115 "SELECT name, type, default_kind FROM system.columns \
116 WHERE database = ? AND table = ? ORDER BY position",
117 )
118 .bind(db)
119 .bind(table),
120 None => endpoint
121 .client()
122 .query(
123 "SELECT name, type, default_kind FROM system.columns \
124 WHERE database = currentDatabase() AND table = ? ORDER BY position",
125 )
126 .bind(table),
127 };
128 query
129 .fetch_all::<ColumnRow>()
130 .await
131 .map_err(|e| SchemaError::Fetch {
132 table: check.display_table(),
133 url: endpoint.url().to_string(),
134 reason: e.to_string(),
135 })
136}
137
138fn table_column_list(cols: &[ColumnRow]) -> String {
139 cols.iter()
140 .map(|c| {
141 if c.default_kind.is_empty() {
142 format!("{} {}", c.name, c.type_)
143 } else {
144 format!("{} {} {}", c.name, c.type_, c.default_kind)
145 }
146 })
147 .collect::<Vec<_>>()
148 .join(", ")
149}
150
151fn aggregate_function_remedy(col: &str, type_: &str) -> Option<String> {
166 let ctor = type_.split_once('(').map(|(name, _)| name.trim())?;
167 if ctor != "AggregateFunction" {
168 return None;
169 }
170 Some(format!(
171 "configured column `{col}` has type `{type_}`: the sink cannot write \
172 aggregate states directly (their wire format is opaque and \
173 version-dependent). Insert raw rows into an `ENGINE = Null` landing \
174 table and let a `MATERIALIZED VIEW` compute the states \
175 (minState/maxState/sumMapState/...) into this table. Client-side \
176 aggregate-state serialization is not supported."
177 ))
178}
179
180pub(crate) async fn validate(
183 check: &SchemaCheck,
184 endpoints: &[Vec<ClickHouseEndpoint>],
185) -> Result<Option<Arc<RowSchema>>, SchemaError> {
186 if check.mode == SchemaValidation::Off {
187 return Ok(None);
188 }
189
190 let mut reference: Option<(String, Vec<ColumnRow>)> = None;
194 for shard in endpoints {
195 for endpoint in shard {
196 let cols = fetch_columns(endpoint, check).await?;
197 if cols.is_empty() {
198 return Err(SchemaError::Mismatch(format!(
199 "sink.clickhouse: table {} not found (or not visible to this user) \
200 on replica {}",
201 check.display_table(),
202 endpoint.url()
203 )));
204 }
205 match &reference {
206 None => reference = Some((endpoint.url().to_string(), cols)),
207 Some((ref_url, ref_cols)) => {
208 if *ref_cols != cols {
209 return Err(SchemaError::Mismatch(format!(
210 "sink.clickhouse: replicas disagree on the schema of {}:\n \
211 {ref_url}: {}\n {}: {}",
212 check.display_table(),
213 table_column_list(ref_cols),
214 endpoint.url(),
215 table_column_list(&cols),
216 )));
217 }
218 }
219 }
220 }
221 }
222 let (ref_url, table_cols) =
223 reference.expect("config validation guarantees at least one replica");
224
225 let mut findings = Vec::new();
226 for col in &check.columns {
227 match table_cols.iter().find(|c| c.name == *col) {
228 None => findings.push(format!(
229 "configured column `{col}` does not exist in the table"
230 )),
231 Some(c) if c.default_kind == "MATERIALIZED" || c.default_kind == "ALIAS" => {
232 findings.push(format!(
233 "configured column `{col}` is {} and cannot be inserted into",
234 c.default_kind
235 ));
236 }
237 Some(c) => {
238 if let Some(msg) = aggregate_function_remedy(col, &c.type_) {
239 findings.push(msg);
240 }
241 }
242 }
243 }
244 for c in &table_cols {
245 if !check.columns.contains(&c.name) && c.default_kind.is_empty() {
246 tracing::warn!(
247 table = %check.display_table(),
248 column = %c.name,
249 r#type = %c.type_,
250 "table column is not in the configured insert columns and has no DEFAULT; \
251 the server will fill type-default values"
252 );
253 }
254 }
255
256 if !findings.is_empty() {
257 let mut msg = format!(
258 "sink.clickhouse: schema validation failed for {} (replica {ref_url}):\n",
259 check.display_table()
260 );
261 for f in &findings {
262 let _ = writeln!(msg, " - {f}");
263 }
264 let _ = writeln!(
265 msg,
266 " table columns: {}",
267 table_column_list(&table_cols)
268 );
269 let _ = write!(msg, " configured columns: {}", check.columns.join(", "));
270 return Err(SchemaError::Mismatch(msg));
271 }
272
273 let columns = check
274 .columns
275 .iter()
276 .map(|name| {
277 let c = table_cols
278 .iter()
279 .find(|c| c.name == *name)
280 .expect("checked above");
281 (name.clone(), typeparse::parse(&c.type_), c.type_.clone())
282 })
283 .collect();
284 Ok(Some(Arc::new(RowSchema {
285 mode: check.mode,
286 table: check.display_table(),
287 columns,
288 })))
289}
290
291pub(crate) fn check_first_record(schema: &RowSchema, fields: &[FieldShape]) -> Result<(), String> {
295 let mut findings = Vec::new();
296 if fields.len() != schema.columns.len() {
297 findings.push(format!(
298 "row struct serialized {} field(s) but {} column(s) are configured \
299 (a #[serde(skip)] attribute shortens rows silently)",
300 fields.len(),
301 schema.columns.len()
302 ));
303 }
304 for (i, (field, (col, ty, ty_str))) in fields.iter().zip(&schema.columns).enumerate() {
305 if field.name != col {
306 findings.push(format!(
307 "position {i}: struct field `{}` vs configured column `{col}`",
308 field.name
309 ));
310 } else if schema.mode == SchemaValidation::Full && !compatible(&field.shape, ty) {
311 findings.push(format!(
312 "position {i}: struct field `{}` ({}) is not compatible with `{col}` {ty_str}",
313 field.name,
314 shape_desc(&field.shape),
315 ));
316 }
317 }
318 if findings.is_empty() {
319 return Ok(());
320 }
321 let mut msg = format!(
322 "row struct does not match configured columns for {}:\n",
323 schema.table
324 );
325 for f in &findings {
326 let _ = writeln!(msg, " - {f}");
327 }
328 let _ = writeln!(
329 msg,
330 " struct fields (declaration order): {}",
331 fields.iter().map(|f| f.name).collect::<Vec<_>>().join(", ")
332 );
333 let _ = write!(
334 msg,
335 " configured columns: {}",
336 schema
337 .columns
338 .iter()
339 .map(|(name, ..)| name.as_str())
340 .collect::<Vec<_>>()
341 .join(", ")
342 );
343 Err(msg)
344}
345
346fn shape_desc(shape: &Shape) -> String {
347 match shape {
348 Shape::Bool => "bool".into(),
349 Shape::I8 => "i8".into(),
350 Shape::I16 => "i16".into(),
351 Shape::I32 => "i32".into(),
352 Shape::I64 => "i64".into(),
353 Shape::I128 => "i128".into(),
354 Shape::U8 => "u8".into(),
355 Shape::U16 => "u16".into(),
356 Shape::U32 => "u32".into(),
357 Shape::U64 => "u64".into(),
358 Shape::U128 => "u128".into(),
359 Shape::F32 => "f32".into(),
360 Shape::F64 => "f64".into(),
361 Shape::Str => "String".into(),
362 Shape::Bytes => "bytes".into(),
363 Shape::Option(inner) => format!("Option<{}>", shape_desc(inner)),
364 Shape::Seq(inner) => format!("Vec<{}>", shape_desc(inner)),
365 Shape::Map(k, v) => format!("Map<{}, {}>", shape_desc(k), shape_desc(v)),
366 Shape::Tuple(elems) => format!(
367 "({})",
368 elems.iter().map(shape_desc).collect::<Vec<_>>().join(", ")
369 ),
370 Shape::Struct(fields) => format!(
371 "struct {{ {} }}",
372 fields
373 .iter()
374 .map(|f| f.name.to_string())
375 .collect::<Vec<_>>()
376 .join(", ")
377 ),
378 Shape::Newtype(name, _) => (*name).to_string(),
379 Shape::Unknown => "_".into(),
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use probe::probe_row;
387 use serde::Serialize;
388
389 fn schema(mode: SchemaValidation, cols: &[(&str, &str)]) -> RowSchema {
390 RowSchema {
391 mode,
392 table: "`orders`".into(),
393 columns: cols
394 .iter()
395 .map(|(n, t)| ((*n).to_string(), typeparse::parse(t), (*t).to_string()))
396 .collect(),
397 }
398 }
399
400 #[derive(Serialize)]
401 struct Row {
402 id: u64,
403 amount: i64,
404 name: String,
405 }
406
407 fn fields() -> Vec<FieldShape> {
408 probe_row(&Row {
409 id: 1,
410 amount: 2,
411 name: "x".into(),
412 })
413 .unwrap()
414 }
415
416 #[test]
417 fn matching_struct_passes_both_modes() {
418 for mode in [SchemaValidation::Names, SchemaValidation::Full] {
419 let s = schema(
420 mode,
421 &[("id", "UInt64"), ("amount", "Int64"), ("name", "String")],
422 );
423 assert_eq!(check_first_record(&s, &fields()), Ok(()));
424 }
425 }
426
427 #[test]
428 fn field_order_mismatch_is_reported_per_position() {
429 let s = schema(
430 SchemaValidation::Names,
431 &[("id", "UInt64"), ("name", "String"), ("amount", "Int64")],
432 );
433 let err = check_first_record(&s, &fields()).unwrap_err();
434 assert!(err.contains("position 1: struct field `amount` vs configured column `name`"));
435 assert!(err.contains("position 2: struct field `name` vs configured column `amount`"));
436 assert!(err.contains("struct fields (declaration order): id, amount, name"));
437 assert!(err.contains("configured columns: id, name, amount"));
438 }
439
440 #[test]
441 fn type_mismatches_only_fail_full_mode() {
442 let cols = [
443 ("id", "UInt64"),
444 ("amount", "DateTime"), ("name", "String"),
446 ];
447 let names = schema(SchemaValidation::Names, &cols);
448 assert_eq!(check_first_record(&names, &fields()), Ok(()));
449
450 let full = schema(SchemaValidation::Full, &cols);
451 let err = check_first_record(&full, &fields()).unwrap_err();
452 assert!(
453 err.contains("struct field `amount` (i64) is not compatible with `amount` DateTime"),
454 "{err}"
455 );
456 }
457
458 #[test]
459 fn field_count_mismatch_names_the_skip_footgun() {
460 let s = schema(SchemaValidation::Names, &[("id", "UInt64")]);
461 let err = check_first_record(&s, &fields()).unwrap_err();
462 assert!(err.contains("serialized 3 field(s) but 1 column(s)"));
463 assert!(err.contains("#[serde(skip)]"));
464 }
465
466 #[test]
467 fn aggregate_function_columns_are_flagged_with_the_null_mv_remedy() {
468 for ty in [
469 "AggregateFunction(min, DateTime)",
470 "AggregateFunction(max, DateTime)",
471 "AggregateFunction(sumMap, Map(String, UInt64))",
472 ] {
473 let msg = aggregate_function_remedy("agg", ty)
474 .unwrap_or_else(|| panic!("`{ty}` should be flagged"));
475 assert!(msg.contains(ty), "{msg}");
476 assert!(msg.contains("Null"), "{msg}");
477 assert!(msg.contains("MATERIALIZED VIEW"), "{msg}");
478 }
479 }
480
481 #[test]
482 fn directly_insertable_types_are_not_flagged() {
483 for ty in [
484 "SimpleAggregateFunction(sum, UInt64)",
486 "SimpleAggregateFunction(sumMap, Map(String, UInt64))",
487 "UInt64",
488 "Map(String, UInt64)",
489 "DateTime",
490 ] {
491 assert_eq!(aggregate_function_remedy("c", ty), None, "{ty}");
492 }
493 }
494}