Skip to main content

clickhouse_kit/
evolve.rs

1//! Additive, bounded schema evolution — grow a dynamic per-tenant table to match
2//! its [`TableSpec`] without ever dropping or modifying existing columns.
3//!
4//! This path is **additive only**: it reports columns the kit declares that the
5//! live table is missing, and emits `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
6//! for each. Live-only columns and type differences are intentionally ignored —
7//! removing or retyping a tenant's column is never this path's job. The added
8//! column's type comes from the kit's own (trusted) [`ColumnTypeSpec::to_ch_type`],
9//! never from the introspected live shape.
10
11use crate::safety::quote_identifier;
12use crate::table::TableSpec;
13
14/// An introspected column from the live table (e.g. from `system.columns`).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct LiveColumn {
17    pub name: String,
18    pub type_name: String,
19}
20
21/// A column the kit declares that the live table is missing. `ch_type` is the
22/// trusted ClickHouse type derived from the kit's [`TableSpec`], not from `live`.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ColumnDiff {
25    pub name: String,
26    pub ch_type: String,
27}
28
29/// Columns present in `table` but absent from `live` (matched by name).
30///
31/// Reports **only** missing (kit-but-not-live) columns. Live-only columns and
32/// type differences are ignored — they are not this additive path's concern.
33/// Each [`ColumnDiff::ch_type`] is computed from the table column's own trusted
34/// [`ColumnTypeSpec::to_ch_type`], never from the live introspection.
35pub fn diff_columns(table: &TableSpec, live: &[LiveColumn]) -> Vec<ColumnDiff> {
36    table
37        .columns
38        .iter()
39        .filter(|c| !live.iter().any(|l| l.name == c.name))
40        .map(|c| ColumnDiff {
41            name: c.name.clone(),
42            ch_type: c.type_spec.to_ch_type(),
43        })
44        .collect()
45}
46
47/// One `ALTER TABLE <table> ADD COLUMN IF NOT EXISTS <col> <ch_type>` per missing
48/// column. The table and column names are backtick-quoted via [`quote_identifier`]
49/// (defense-in-depth); the type is the trusted [`ColumnDiff::ch_type`]. Returns an
50/// empty vec when nothing is missing.
51pub fn alter_add_columns_sql(table: &TableSpec, missing: &[ColumnDiff]) -> Vec<String> {
52    let quoted_table = quote_identifier(&table.name);
53    missing
54        .iter()
55        .map(|d| {
56            format!(
57                "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}",
58                quoted_table,
59                quote_identifier(&d.name),
60                d.ch_type,
61            )
62        })
63        .collect()
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::safety::{ColumnTypeSpec, ScalarType};
70    use crate::table::ColumnSpec;
71
72    fn col(name: &str, t: ColumnTypeSpec) -> ColumnSpec {
73        ColumnSpec {
74            name: name.into(),
75            type_spec: t,
76            default: None,
77        }
78    }
79
80    fn sample() -> TableSpec {
81        TableSpec {
82            name: "events".into(),
83            columns: vec![
84                col("id", ColumnTypeSpec::Scalar(ScalarType::Uuid)),
85                col("ts", ColumnTypeSpec::Scalar(ScalarType::DateTime64)),
86                col("name", ColumnTypeSpec::Scalar(ScalarType::String)),
87            ],
88            engine: "MergeTree()".into(),
89            order_by: vec!["id".into()],
90            partition_by: None,
91            ttl: None,
92            indexes: vec![],
93            settings: vec![],
94        }
95    }
96
97    fn live(name: &str, type_name: &str) -> LiveColumn {
98        LiveColumn {
99            name: name.into(),
100            type_name: type_name.into(),
101        }
102    }
103
104    #[test]
105    fn diff_finds_only_missing_columns() {
106        let table = sample();
107        // `id` is present; `ts` and `name` are missing. `extra` is live-only.
108        let live = [live("id", "UUID"), live("extra", "String")];
109        let diff = diff_columns(&table, &live);
110        assert_eq!(
111            diff,
112            vec![
113                ColumnDiff {
114                    name: "ts".into(),
115                    ch_type: "DateTime64(3)".into(),
116                },
117                ColumnDiff {
118                    name: "name".into(),
119                    ch_type: "String".into(),
120                },
121            ]
122        );
123    }
124
125    #[test]
126    fn diff_ignores_live_only_and_retyped_columns() {
127        let table = sample();
128        // Every kit column is present live, but `id`/`ts`/`name` carry *different*
129        // live types, and there's a live-only `extra`. Type differences are not
130        // this path's concern, so the diff must be empty.
131        let live = [
132            live("id", "String"),
133            live("ts", "DateTime"),
134            live("name", "Int64"),
135            live("extra", "String"),
136        ];
137        assert!(diff_columns(&table, &live).is_empty());
138    }
139
140    #[test]
141    fn diff_ch_type_comes_from_kit_not_live() {
142        let table = sample();
143        // `ts` is missing live; its ch_type must be the kit's trusted DateTime64(3),
144        // regardless of any live type that might claim otherwise.
145        let live = [live("id", "UUID"), live("name", "String")];
146        let diff = diff_columns(&table, &live);
147        assert_eq!(
148            diff,
149            vec![ColumnDiff {
150                name: "ts".into(),
151                ch_type: "DateTime64(3)".into(),
152            }]
153        );
154    }
155
156    #[test]
157    fn alter_emits_add_column_if_not_exists_with_quoted_identifiers() {
158        let table = sample();
159        let missing = vec![
160            ColumnDiff {
161                name: "ts".into(),
162                ch_type: "DateTime64(3)".into(),
163            },
164            ColumnDiff {
165                name: "name".into(),
166                ch_type: "String".into(),
167            },
168        ];
169        let sql = alter_add_columns_sql(&table, &missing);
170        assert_eq!(
171            sql,
172            vec![
173                "ALTER TABLE `events` ADD COLUMN IF NOT EXISTS `ts` DateTime64(3)".to_string(),
174                "ALTER TABLE `events` ADD COLUMN IF NOT EXISTS `name` String".to_string(),
175            ]
176        );
177    }
178
179    #[test]
180    fn alter_backtick_quotes_table_and_column() {
181        let table = sample();
182        let missing = vec![ColumnDiff {
183            name: "name".into(),
184            ch_type: "String".into(),
185        }];
186        let sql = alter_add_columns_sql(&table, &missing);
187        assert_eq!(sql.len(), 1);
188        assert!(sql[0].contains("ALTER TABLE `events`"));
189        assert!(sql[0].contains("ADD COLUMN IF NOT EXISTS `name`"));
190    }
191
192    #[test]
193    fn empty_when_in_sync() {
194        let table = sample();
195        let live = [
196            live("id", "UUID"),
197            live("ts", "DateTime64(3)"),
198            live("name", "String"),
199        ];
200        let diff = diff_columns(&table, &live);
201        assert!(diff.is_empty());
202        assert!(alter_add_columns_sql(&table, &diff).is_empty());
203    }
204}