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 }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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); 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); 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 #[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}