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)]
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        }
275    }
276
277    fn diff_one(target: &Table, source: &Table) -> Vec<TableOpEntry> {
278        let mut out = Vec::new();
279        diff_columns(target, source, &mut out);
280        out
281    }
282
283    // ---- adds ----
284
285    #[test]
286    fn add_nullable_column_is_safe() {
287        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
288        let source = tbl(vec![
289            col("id", ColumnType::BigInt, false),
290            col("email", ColumnType::Text, true),
291        ]);
292        let ops = diff_one(&target, &source);
293        assert_eq!(ops.len(), 1);
294        assert!(matches!(ops[0].op, TableOp::AddColumn(_)));
295        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
296    }
297
298    #[test]
299    fn add_not_null_column_with_default_is_safe() {
300        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
301        let mut new_col = col("created", ColumnType::BigInt, false);
302        new_col.default = Some(DefaultExpr::Literal(LiteralValue::Integer(0)));
303        let source = tbl(vec![col("id", ColumnType::BigInt, false), new_col]);
304        let ops = diff_one(&target, &source);
305        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
306    }
307
308    #[test]
309    fn add_not_null_column_without_default_requires_approval() {
310        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
311        let source = tbl(vec![
312            col("id", ColumnType::BigInt, false),
313            col("email", ColumnType::Text, false),
314        ]);
315        let ops = diff_one(&target, &source);
316        assert_eq!(ops.len(), 1);
317        assert!(ops[0].destructiveness.requires_approval());
318        assert!(!ops[0].destructiveness.data_loss_risk());
319    }
320
321    // ---- drops ----
322
323    #[test]
324    fn drop_column_is_data_loss_warning() {
325        let target = tbl(vec![
326            col("id", ColumnType::BigInt, false),
327            col("email", ColumnType::Text, true),
328        ]);
329        let source = tbl(vec![col("id", ColumnType::BigInt, false)]);
330        let ops = diff_one(&target, &source);
331        assert_eq!(ops.len(), 1);
332        assert!(matches!(ops[0].op, TableOp::DropColumn { .. }));
333        assert!(ops[0].destructiveness.data_loss_risk());
334    }
335
336    // ---- type changes / widening ----
337
338    #[test]
339    fn integer_to_bigint_is_widening_safe() {
340        let target = tbl(vec![col("c", ColumnType::Integer, true)]);
341        let source = tbl(vec![col("c", ColumnType::BigInt, true)]);
342        let ops = diff_one(&target, &source);
343        assert_eq!(ops.len(), 1);
344        assert!(matches!(ops[0].op, TableOp::AlterColumnType { .. }));
345        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
346    }
347
348    #[test]
349    fn smallint_to_bigint_is_widening_safe() {
350        let target = tbl(vec![col("c", ColumnType::SmallInt, true)]);
351        let source = tbl(vec![col("c", ColumnType::BigInt, true)]);
352        let ops = diff_one(&target, &source);
353        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
354    }
355
356    #[test]
357    fn bigint_to_integer_is_data_loss() {
358        let target = tbl(vec![col("c", ColumnType::BigInt, true)]);
359        let source = tbl(vec![col("c", ColumnType::Integer, true)]);
360        let ops = diff_one(&target, &source);
361        assert!(ops[0].destructiveness.data_loss_risk());
362    }
363
364    #[test]
365    fn varchar_widening_is_safe() {
366        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
367        let source = tbl(vec![col("c", ColumnType::Varchar { len: Some(100) }, true)]);
368        let ops = diff_one(&target, &source);
369        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
370    }
371
372    #[test]
373    fn varchar_to_unbounded_is_safe() {
374        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
375        let source = tbl(vec![col("c", ColumnType::Varchar { len: None }, true)]);
376        let ops = diff_one(&target, &source);
377        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
378    }
379
380    #[test]
381    fn varchar_to_text_is_safe() {
382        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
383        let source = tbl(vec![col("c", ColumnType::Text, true)]);
384        let ops = diff_one(&target, &source);
385        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
386    }
387
388    #[test]
389    fn varchar_narrowing_is_data_loss() {
390        let target = tbl(vec![col("c", ColumnType::Varchar { len: Some(100) }, true)]);
391        let source = tbl(vec![col("c", ColumnType::Varchar { len: Some(50) }, true)]);
392        let ops = diff_one(&target, &source);
393        assert!(ops[0].destructiveness.data_loss_risk());
394    }
395
396    #[test]
397    fn cross_family_change_is_data_loss() {
398        let target = tbl(vec![col("c", ColumnType::Text, true)]);
399        let source = tbl(vec![col("c", ColumnType::Integer, true)]);
400        let ops = diff_one(&target, &source);
401        assert!(ops[0].destructiveness.data_loss_risk());
402    }
403
404    // ---- nullability ----
405
406    #[test]
407    fn drop_not_null_is_safe() {
408        let target = tbl(vec![col("c", ColumnType::Text, false)]);
409        let source = tbl(vec![col("c", ColumnType::Text, true)]);
410        let ops = diff_one(&target, &source);
411        assert_eq!(ops.len(), 1);
412        match &ops[0].op {
413            TableOp::SetColumnNullable { nullable, .. } => assert!(*nullable),
414            other => panic!("got {other:?}"),
415        }
416        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
417    }
418
419    #[test]
420    fn set_not_null_requires_approval() {
421        let target = tbl(vec![col("c", ColumnType::Text, true)]);
422        let source = tbl(vec![col("c", ColumnType::Text, false)]);
423        let ops = diff_one(&target, &source);
424        assert_eq!(ops.len(), 1);
425        assert!(ops[0].destructiveness.requires_approval());
426        assert!(!ops[0].destructiveness.data_loss_risk());
427    }
428
429    // ---- defaults ----
430
431    #[test]
432    fn set_default_is_safe() {
433        let target = tbl(vec![col("c", ColumnType::Integer, true)]);
434        let mut to = col("c", ColumnType::Integer, true);
435        to.default = Some(DefaultExpr::Literal(LiteralValue::Integer(0)));
436        let source = tbl(vec![to]);
437        let ops = diff_one(&target, &source);
438        assert_eq!(ops.len(), 1);
439        assert!(matches!(ops[0].op, TableOp::SetColumnDefault { .. }));
440        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
441    }
442
443    #[test]
444    fn drop_default_is_safe() {
445        let mut from = col("c", ColumnType::Integer, true);
446        from.default = Some(DefaultExpr::Literal(LiteralValue::Integer(0)));
447        let target = tbl(vec![from]);
448        let source = tbl(vec![col("c", ColumnType::Integer, true)]);
449        let ops = diff_one(&target, &source);
450        assert_eq!(ops.len(), 1);
451        match &ops[0].op {
452            TableOp::SetColumnDefault { default, .. } => assert!(default.is_none()),
453            other => panic!("got {other:?}"),
454        }
455    }
456
457    // ---- identity / generated ----
458
459    #[test]
460    fn identity_change_requires_approval() {
461        use crate::ir::column::{Identity, IdentityKind, SequenceOptions};
462        let target = tbl(vec![col("c", ColumnType::BigInt, false)]);
463        let mut to = col("c", ColumnType::BigInt, false);
464        to.identity = Some(Identity {
465            kind: IdentityKind::ByDefault,
466            sequence: SequenceOptions {
467                start: 1,
468                increment: 1,
469                min_value: None,
470                max_value: None,
471                cache: 1,
472                cycle: false,
473            },
474        });
475        let source = tbl(vec![to]);
476        let ops = diff_one(&target, &source);
477        assert_eq!(ops.len(), 1);
478        assert!(ops[0].destructiveness.requires_approval());
479    }
480
481    // ---- comment ----
482
483    #[test]
484    fn column_comment_change_is_safe() {
485        let target = tbl(vec![col("c", ColumnType::Integer, true)]);
486        let mut to = col("c", ColumnType::Integer, true);
487        to.comment = Some("the c column".into());
488        let source = tbl(vec![to]);
489        let ops = diff_one(&target, &source);
490        assert_eq!(ops.len(), 1);
491        assert!(matches!(ops[0].op, TableOp::SetColumnComment { .. }));
492        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
493    }
494
495    // ---- equality / order ----
496
497    #[test]
498    fn equal_columns_emit_nothing() {
499        let target = tbl(vec![col("id", ColumnType::BigInt, false)]);
500        let source = tbl(vec![col("id", ColumnType::BigInt, false)]);
501        let ops = diff_one(&target, &source);
502        assert!(ops.is_empty());
503    }
504
505    #[test]
506    fn column_reorder_is_ignored_in_v0_1() {
507        let target = tbl(vec![
508            col("a", ColumnType::Integer, true),
509            col("b", ColumnType::Integer, true),
510        ]);
511        let source = tbl(vec![
512            col("b", ColumnType::Integer, true),
513            col("a", ColumnType::Integer, true),
514        ]);
515        let ops = diff_one(&target, &source);
516        assert!(ops.is_empty(), "v0.1 ignores logical column order");
517    }
518
519    // ---- storage ----
520
521    #[test]
522    fn storage_change_emits_safe_op() {
523        let mut from_col = col("doc", ColumnType::Text, true);
524        from_col.storage = Some(StorageKind::Extended); // text default → canon would strip
525        let mut to_col = col("doc", ColumnType::Text, true);
526        to_col.storage = Some(StorageKind::External);
527        let target = tbl(vec![from_col]);
528        let source = tbl(vec![to_col]);
529        let ops = diff_one(&target, &source);
530        assert_eq!(ops.len(), 1);
531        assert!(matches!(
532            ops[0].op,
533            TableOp::SetColumnStorage {
534                from: StorageKind::Extended,
535                to: StorageKind::External,
536                ..
537            }
538        ));
539        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
540    }
541
542    #[test]
543    fn storage_none_vs_type_default_is_noop() {
544        let from_col = col("doc", ColumnType::Text, true);
545        let mut to_col = col("doc", ColumnType::Text, true);
546        to_col.storage = Some(StorageKind::Extended); // text default
547        let target = tbl(vec![from_col]);
548        let source = tbl(vec![to_col]);
549        let ops = diff_one(&target, &source);
550        assert!(
551            ops.is_empty(),
552            "None and Some(type_default) must collapse to the same effective storage"
553        );
554    }
555
556    // ---- compression ----
557
558    #[test]
559    fn compression_change_emits_safe_op() {
560        let mut from_col = col("blob", ColumnType::Bytea, true);
561        from_col.compression = Some(Compression::Pglz);
562        let mut to_col = col("blob", ColumnType::Bytea, true);
563        to_col.compression = Some(Compression::Lz4);
564        let target = tbl(vec![from_col]);
565        let source = tbl(vec![to_col]);
566        let ops = diff_one(&target, &source);
567        assert_eq!(ops.len(), 1);
568        assert!(matches!(
569            ops[0].op,
570            TableOp::SetColumnCompression {
571                compression: Some(Compression::Lz4),
572                ..
573            }
574        ));
575        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
576    }
577
578    #[test]
579    fn compression_to_cluster_default_emits_none() {
580        let mut from_col = col("blob", ColumnType::Bytea, true);
581        from_col.compression = Some(Compression::Lz4);
582        let to_col = col("blob", ColumnType::Bytea, true);
583        let target = tbl(vec![from_col]);
584        let source = tbl(vec![to_col]);
585        let ops = diff_one(&target, &source);
586        assert_eq!(ops.len(), 1);
587        assert!(matches!(
588            ops[0].op,
589            TableOp::SetColumnCompression {
590                compression: None,
591                ..
592            }
593        ));
594    }
595}