Skip to main content

pgevolve_core/diff/
columns.rs

1//! Column-level diffing inside an `AlterTable`.
2//!
3//! Pairs columns by name. Logical column order is intentionally **not**
4//! enforced in v0.1 — the IR records it, but reordering would require a table
5//! rewrite. If the user wants a specific physical order, they must drop and
6//! recreate the table.
7//!
8//! ## Widening whitelist
9//!
10//! `is_widening` returns true only for type changes Postgres can perform
11//! without rewriting data: `int2 → int4 → int8`, `varchar(N) → varchar(M>N)`,
12//! `varchar(*) → text`. Everything else is conservative and tagged
13//! [`Destructiveness::RequiresApprovalAndDataLossWarning`].
14
15use std::collections::BTreeMap;
16
17use crate::identifier::Identifier;
18use crate::ir::canon::filter_pg_defaults::type_default_storage;
19use crate::ir::column::Column;
20use crate::ir::column_type::ColumnType;
21use crate::ir::table::Table;
22
23use super::destructiveness::Destructiveness;
24use super::table_op::{TableOp, TableOpEntry};
25
26/// Diff columns in `target` against `source`, appending entries to `out`.
27pub fn diff_columns(target: &Table, source: &Table, out: &mut Vec<TableOpEntry>) {
28    let target_map: BTreeMap<&Identifier, &Column> =
29        target.columns.iter().map(|c| (&c.name, c)).collect();
30    let source_map: BTreeMap<&Identifier, &Column> =
31        source.columns.iter().map(|c| (&c.name, c)).collect();
32
33    for (name, source_col) in &source_map {
34        if !target_map.contains_key(name) {
35            out.push(add_column_entry((*source_col).clone()));
36        }
37    }
38
39    for (name, target_col) in &target_map {
40        match source_map.get(name) {
41            None => {
42                out.push(TableOpEntry {
43                    op: TableOp::DropColumn {
44                        name: (*name).clone(),
45                        is_populated: false,
46                    },
47                    destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
48                        reason: format!("drops column {name}"),
49                    },
50                });
51            }
52            Some(source_col) => {
53                diff_column(target_col, source_col, out);
54            }
55        }
56    }
57}
58
59fn add_column_entry(col: Column) -> TableOpEntry {
60    let safe = col.nullable || col.default.is_some();
61    let destructiveness = if safe {
62        Destructiveness::Safe
63    } else {
64        Destructiveness::RequiresApproval {
65            reason: format!(
66                "adds NOT NULL column {} with no default — fails on a non-empty table",
67                col.name
68            ),
69        }
70    };
71    TableOpEntry {
72        op: TableOp::AddColumn(col),
73        destructiveness,
74    }
75}
76
77// Each attribute's diff block is a self-contained guard + push; extracting
78// them into sub-functions would scatter the logic across many tiny helpers
79// without reducing cognitive load.
80#[allow(clippy::too_many_lines)] // exhaustive per-column-attribute diff with destructiveness classification.
81fn diff_column(target: &Column, source: &Column, out: &mut Vec<TableOpEntry>) {
82    if target.ty != source.ty {
83        let destructiveness = if is_widening(&target.ty, &source.ty) {
84            Destructiveness::Safe
85        } else {
86            Destructiveness::RequiresApprovalAndDataLossWarning {
87                reason: format!(
88                    "narrows or changes type family on column {} ({} -> {})",
89                    target.name,
90                    target.ty.render_sql(),
91                    source.ty.render_sql()
92                ),
93            }
94        };
95        out.push(TableOpEntry {
96            op: TableOp::AlterColumnType {
97                name: target.name.clone(),
98                from: target.ty.clone(),
99                to: source.ty.clone(),
100                using: None,
101            },
102            destructiveness,
103        });
104    }
105
106    if target.nullable != source.nullable {
107        let destructiveness = if source.nullable {
108            // Going to nullable is always safe.
109            Destructiveness::Safe
110        } else {
111            Destructiveness::RequiresApproval {
112                reason: format!(
113                    "SET NOT NULL on {} may fail if column has NULL values",
114                    target.name
115                ),
116            }
117        };
118        out.push(TableOpEntry {
119            op: TableOp::SetColumnNullable {
120                name: target.name.clone(),
121                nullable: source.nullable,
122            },
123            destructiveness,
124        });
125    }
126
127    if target.default != source.default {
128        out.push(TableOpEntry {
129            op: TableOp::SetColumnDefault {
130                name: target.name.clone(),
131                default: source.default.clone(),
132            },
133            destructiveness: Destructiveness::Safe,
134        });
135    }
136
137    if target.identity != source.identity {
138        out.push(TableOpEntry {
139            op: TableOp::SetColumnIdentity {
140                name: target.name.clone(),
141                identity: source.identity.clone(),
142            },
143            destructiveness: Destructiveness::RequiresApproval {
144                reason: "identity changes can fail or rewrite data".into(),
145            },
146        });
147    }
148
149    if target.generated != source.generated {
150        out.push(TableOpEntry {
151            op: TableOp::SetColumnGenerated {
152                name: target.name.clone(),
153                generated: source.generated.clone(),
154            },
155            destructiveness: Destructiveness::RequiresApproval {
156                reason: "generated-column changes can fail or rewrite data".into(),
157            },
158        });
159    }
160
161    if target.comment != source.comment {
162        out.push(TableOpEntry {
163            op: TableOp::SetColumnComment {
164                name: target.name.clone(),
165                comment: source.comment.clone(),
166            },
167            destructiveness: Destructiveness::Safe,
168        });
169    }
170
171    // Storage: resolve None to the type default on both sides so the
172    // emitted op is always explicit. Carries both `from` and `to` so the
173    // lint rule can detect downgrades.
174    {
175        let from = target
176            .storage
177            .unwrap_or_else(|| type_default_storage(&target.ty));
178        let to = source
179            .storage
180            .unwrap_or_else(|| type_default_storage(&source.ty));
181        if from != to {
182            out.push(TableOpEntry {
183                op: TableOp::SetColumnStorage {
184                    name: target.name.clone(),
185                    from,
186                    to,
187                },
188                destructiveness: Destructiveness::Safe,
189            });
190        }
191    }
192
193    if target.compression != source.compression {
194        out.push(TableOpEntry {
195            op: TableOp::SetColumnCompression {
196                name: target.name.clone(),
197                compression: source.compression,
198            },
199            destructiveness: Destructiveness::Safe,
200        });
201    }
202}
203
204/// Postgres can perform these type changes in place without rewriting data.
205///
206/// `char(N)` is intentionally absent from the whitelist: char→varchar/text
207/// changes semantics (pg right-pads char), and char(N)→char(M) is treated
208/// like other length changes — conservative for v0.1.
209//
210// Each arm is left as a separate case to keep the whitelist readable; the
211// extra `match_same_arms` lint hurts readability here without giving us
212// anything in return.
213#[allow(clippy::match_same_arms)]
214fn is_widening(from: &ColumnType, to: &ColumnType) -> bool {
215    use ColumnType::{BigInt, Integer, SmallInt, Text, Varchar};
216    match (from, to) {
217        // Integer family: small → larger only.
218        (SmallInt, Integer | BigInt) => true,
219        (Integer, BigInt) => true,
220        // varchar(N) → varchar(M) when M > N.
221        (Varchar { len: Some(n) }, Varchar { len: Some(m) }) => m > n,
222        // varchar(N) → unbounded varchar.
223        (Varchar { len: Some(_) }, Varchar { len: None }) => true,
224        // varchar(N) → text (text is unbounded).
225        (Varchar { len: Some(_) }, Text) => true,
226        _ => false,
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::identifier::QualifiedName;
234    use crate::ir::column::{Compression, StorageKind};
235    use crate::ir::default_expr::{DefaultExpr, LiteralValue};
236
237    fn id(s: &str) -> Identifier {
238        Identifier::from_unquoted(s).unwrap()
239    }
240
241    fn qn(name: &str) -> QualifiedName {
242        QualifiedName::new(id("app"), id(name))
243    }
244
245    fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
246        Column {
247            name: id(name),
248            ty,
249            nullable,
250            default: None,
251            identity: None,
252            generated: None,
253            collation: None,
254            storage: None,
255            compression: None,
256            comment: None,
257        }
258    }
259
260    fn tbl(cols: Vec<Column>) -> Table {
261        Table {
262            qname: qn("users"),
263            columns: cols,
264            constraints: vec![],
265            partition_by: None,
266            partition_of: None,
267            comment: None,
268            owner: None,
269            grants: vec![],
270            rls_enabled: false,
271            rls_forced: false,
272            policies: vec![],
273            storage: crate::ir::reloptions::TableStorageOptions::default(),
274            access_method: None,
275            tablespace: None,
276        }
277    }
278
279    fn diff_one(target: &Table, source: &Table) -> Vec<TableOpEntry> {
280        let mut out = Vec::new();
281        diff_columns(target, source, &mut out);
282        out
283    }
284
285    // ---- adds ----
286
287    #[test]
288    fn add_nullable_column_is_safe() {
289        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
290        let source = tbl(vec![
291            col("id", ColumnType::BigInt, false),
292            col("email", ColumnType::Text, true),
293        ]);
294        let ops = diff_one(&target, &source);
295        assert_eq!(ops.len(), 1);
296        assert!(matches!(ops[0].op, TableOp::AddColumn(_)));
297        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
298    }
299
300    #[test]
301    fn add_not_null_column_with_default_is_safe() {
302        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
303        let mut new_col = col("created", ColumnType::BigInt, false);
304        new_col.default = Some(DefaultExpr::Literal(LiteralValue::Integer(0)));
305        let source = tbl(vec![col("id", ColumnType::BigInt, false), new_col]);
306        let ops = diff_one(&target, &source);
307        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
308    }
309
310    #[test]
311    fn add_not_null_column_without_default_requires_approval() {
312        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
313        let source = tbl(vec![
314            col("id", ColumnType::BigInt, false),
315            col("email", ColumnType::Text, false),
316        ]);
317        let ops = diff_one(&target, &source);
318        assert_eq!(ops.len(), 1);
319        assert!(ops[0].destructiveness.requires_approval());
320        assert!(!ops[0].destructiveness.data_loss_risk());
321    }
322
323    // ---- drops ----
324
325    #[test]
326    fn drop_column_is_data_loss_warning() {
327        let target = tbl(vec![
328            col("id", ColumnType::BigInt, false),
329            col("email", ColumnType::Text, true),
330        ]);
331        let source = tbl(vec![col("id", ColumnType::BigInt, false)]);
332        let ops = diff_one(&target, &source);
333        assert_eq!(ops.len(), 1);
334        assert!(matches!(ops[0].op, TableOp::DropColumn { .. }));
335        assert!(ops[0].destructiveness.data_loss_risk());
336    }
337
338    // ---- type changes / widening ----
339
340    #[test]
341    fn integer_to_bigint_is_widening_safe() {
342        let target = tbl(vec![col("c", ColumnType::Integer, true)]);
343        let source = tbl(vec![col("c", ColumnType::BigInt, true)]);
344        let ops = diff_one(&target, &source);
345        assert_eq!(ops.len(), 1);
346        assert!(matches!(ops[0].op, TableOp::AlterColumnType { .. }));
347        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
348    }
349
350    #[test]
351    fn smallint_to_bigint_is_widening_safe() {
352        let target = tbl(vec![col("c", ColumnType::SmallInt, true)]);
353        let source = tbl(vec![col("c", ColumnType::BigInt, true)]);
354        let ops = diff_one(&target, &source);
355        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
356    }
357
358    #[test]
359    fn bigint_to_integer_is_data_loss() {
360        let target = tbl(vec![col("c", ColumnType::BigInt, true)]);
361        let source = tbl(vec![col("c", ColumnType::Integer, true)]);
362        let ops = diff_one(&target, &source);
363        assert!(ops[0].destructiveness.data_loss_risk());
364    }
365
366    #[test]
367    fn varchar_widening_is_safe() {
368        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
369        let source = tbl(vec![col("c", ColumnType::Varchar { len: Some(100) }, true)]);
370        let ops = diff_one(&target, &source);
371        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
372    }
373
374    #[test]
375    fn varchar_to_unbounded_is_safe() {
376        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
377        let source = tbl(vec![col("c", ColumnType::Varchar { len: None }, true)]);
378        let ops = diff_one(&target, &source);
379        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
380    }
381
382    #[test]
383    fn varchar_to_text_is_safe() {
384        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
385        let source = tbl(vec![col("c", ColumnType::Text, true)]);
386        let ops = diff_one(&target, &source);
387        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
388    }
389
390    #[test]
391    fn varchar_narrowing_is_data_loss() {
392        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(100) }, true)]);
393        let source = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
394        let ops = diff_one(&target, &source);
395        assert!(ops[0].destructiveness.data_loss_risk());
396    }
397
398    #[test]
399    fn cross_family_change_is_data_loss() {
400        let target = tbl(vec![col("c", ColumnType::Text, true)]);
401        let source = tbl(vec![col("c", ColumnType::Integer, true)]);
402        let ops = diff_one(&target, &source);
403        assert!(ops[0].destructiveness.data_loss_risk());
404    }
405
406    // ---- nullability ----
407
408    #[test]
409    fn drop_not_null_is_safe() {
410        let target = tbl(vec![col("c", ColumnType::Text, false)]);
411        let source = tbl(vec![col("c", ColumnType::Text, true)]);
412        let ops = diff_one(&target, &source);
413        assert_eq!(ops.len(), 1);
414        match &ops[0].op {
415            TableOp::SetColumnNullable { nullable, .. } => assert!(*nullable),
416            other => panic!("got {other:?}"),
417        }
418        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
419    }
420
421    #[test]
422    fn set_not_null_requires_approval() {
423        let target = tbl(vec![col("c", ColumnType::Text, true)]);
424        let source = tbl(vec![col("c", ColumnType::Text, false)]);
425        let ops = diff_one(&target, &source);
426        assert_eq!(ops.len(), 1);
427        assert!(ops[0].destructiveness.requires_approval());
428        assert!(!ops[0].destructiveness.data_loss_risk());
429    }
430
431    // ---- defaults ----
432
433    #[test]
434    fn set_default_is_safe() {
435        let target = tbl(vec![col("c", ColumnType::Integer, true)]);
436        let mut to = col("c", ColumnType::Integer, true);
437        to.default = Some(DefaultExpr::Literal(LiteralValue::Integer(0)));
438        let source = tbl(vec![to]);
439        let ops = diff_one(&target, &source);
440        assert_eq!(ops.len(), 1);
441        assert!(matches!(ops[0].op, TableOp::SetColumnDefault { .. }));
442        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
443    }
444
445    #[test]
446    fn drop_default_is_safe() {
447        let mut from = col("c", ColumnType::Integer, true);
448        from.default = Some(DefaultExpr::Literal(LiteralValue::Integer(0)));
449        let target = tbl(vec![from]);
450        let source = tbl(vec![col("c", ColumnType::Integer, true)]);
451        let ops = diff_one(&target, &source);
452        assert_eq!(ops.len(), 1);
453        match &ops[0].op {
454            TableOp::SetColumnDefault { default, .. } => assert!(default.is_none()),
455            other => panic!("got {other:?}"),
456        }
457    }
458
459    // ---- identity / generated ----
460
461    #[test]
462    fn identity_change_requires_approval() {
463        use crate::ir::column::{Identity, IdentityKind, SequenceOptions};
464        let target = tbl(vec![col("c", ColumnType::BigInt, false)]);
465        let mut to = col("c", ColumnType::BigInt, false);
466        to.identity = Some(Identity {
467            kind: IdentityKind::ByDefault,
468            sequence: SequenceOptions {
469                start: 1,
470                increment: 1,
471                min_value: None,
472                max_value: None,
473                cache: 1,
474                cycle: false,
475            },
476        });
477        let source = tbl(vec![to]);
478        let ops = diff_one(&target, &source);
479        assert_eq!(ops.len(), 1);
480        assert!(ops[0].destructiveness.requires_approval());
481    }
482
483    // ---- comment ----
484
485    #[test]
486    fn column_comment_change_is_safe() {
487        let target = tbl(vec![col("c", ColumnType::Integer, true)]);
488        let mut to = col("c", ColumnType::Integer, true);
489        to.comment = Some("the c column".into());
490        let source = tbl(vec![to]);
491        let ops = diff_one(&target, &source);
492        assert_eq!(ops.len(), 1);
493        assert!(matches!(ops[0].op, TableOp::SetColumnComment { .. }));
494        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
495    }
496
497    // ---- equality / order ----
498
499    #[test]
500    fn equal_columns_emit_nothing() {
501        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
502        let source = tbl(vec![col("id", ColumnType::BigInt, false)]);
503        let ops = diff_one(&target, &source);
504        assert!(ops.is_empty());
505    }
506
507    #[test]
508    fn column_reorder_is_ignored_in_v0_1() {
509        let target = tbl(vec![
510            col("a", ColumnType::Integer, true),
511            col("b", ColumnType::Integer, true),
512        ]);
513        let source = tbl(vec![
514            col("b", ColumnType::Integer, true),
515            col("a", ColumnType::Integer, true),
516        ]);
517        let ops = diff_one(&target, &source);
518        assert!(ops.is_empty(), "v0.1 ignores logical column order");
519    }
520
521    // ---- storage ----
522
523    #[test]
524    fn storage_change_emits_safe_op() {
525        let mut from_col = col("doc", ColumnType::Text, true);
526        from_col.storage = Some(StorageKind::Extended); // text default → canon would strip
527        let mut to_col = col("doc", ColumnType::Text, true);
528        to_col.storage = Some(StorageKind::External);
529        let target = tbl(vec![from_col]);
530        let source = tbl(vec![to_col]);
531        let ops = diff_one(&target, &source);
532        assert_eq!(ops.len(), 1);
533        assert!(matches!(
534            ops[0].op,
535            TableOp::SetColumnStorage {
536                from: StorageKind::Extended,
537                to: StorageKind::External,
538                ..
539            }
540        ));
541        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
542    }
543
544    #[test]
545    fn storage_none_vs_type_default_is_noop() {
546        let from_col = col("doc", ColumnType::Text, true);
547        let mut to_col = col("doc", ColumnType::Text, true);
548        to_col.storage = Some(StorageKind::Extended); // text default
549        let target = tbl(vec![from_col]);
550        let source = tbl(vec![to_col]);
551        let ops = diff_one(&target, &source);
552        assert!(
553            ops.is_empty(),
554            "None and Some(type_default) must collapse to the same effective storage"
555        );
556    }
557
558    // ---- compression ----
559
560    #[test]
561    fn compression_change_emits_safe_op() {
562        let mut from_col = col("blob", ColumnType::Bytea, true);
563        from_col.compression = Some(Compression::Pglz);
564        let mut to_col = col("blob", ColumnType::Bytea, true);
565        to_col.compression = Some(Compression::Lz4);
566        let target = tbl(vec![from_col]);
567        let source = tbl(vec![to_col]);
568        let ops = diff_one(&target, &source);
569        assert_eq!(ops.len(), 1);
570        assert!(matches!(
571            ops[0].op,
572            TableOp::SetColumnCompression {
573                compression: Some(Compression::Lz4),
574                ..
575            }
576        ));
577        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
578    }
579
580    #[test]
581    fn compression_to_cluster_default_emits_none() {
582        let mut from_col = col("blob", ColumnType::Bytea, true);
583        from_col.compression = Some(Compression::Lz4);
584        let to_col = col("blob", ColumnType::Bytea, true);
585        let target = tbl(vec![from_col]);
586        let source = tbl(vec![to_col]);
587        let ops = diff_one(&target, &source);
588        assert_eq!(ops.len(), 1);
589        assert!(matches!(
590            ops[0].op,
591            TableOp::SetColumnCompression {
592                compression: None,
593                ..
594            }
595        ));
596    }
597}