1use 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
26pub 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#[allow(clippy::too_many_lines)] fn 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 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 {
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#[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 (SmallInt, Integer | BigInt) => true,
219 (Integer, BigInt) => true,
220 (Varchar { len: Some(n) }, Varchar { len: Some(m) }) => m > n,
222 (Varchar { len: Some(_) }, Varchar { len: None }) => true,
224 (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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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); 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); 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 #[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}