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