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