1use super::{DataMigrationSql, MigrationAction};
2use crate::schema::TableConstraint;
3use std::borrow::Cow;
4use std::fmt;
5
6impl fmt::Display for MigrationAction {
7 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
8 write_migration_action(f, self)
9 }
10}
11
12fn write_migration_action(f: &mut fmt::Formatter<'_>, action: &MigrationAction) -> fmt::Result {
13 match action {
14 MigrationAction::CreateTable { table, .. } => write!(f, "CreateTable: {table}"),
15 MigrationAction::DeleteTable { table } => write!(f, "DeleteTable: {table}"),
16 MigrationAction::AddColumn { table, column, .. } => {
17 write!(f, "AddColumn: {}.{}", table, column.name)
18 }
19 MigrationAction::RenameColumn { table, from, to } => {
20 write!(f, "RenameColumn: {table}.{from} -> {to}")
21 }
22 MigrationAction::DeleteColumn { table, column } => {
23 write!(f, "DeleteColumn: {table}.{column}")
24 }
25 MigrationAction::ModifyColumnType { table, column, .. } => {
26 write!(f, "ModifyColumnType: {table}.{column}")
27 }
28 MigrationAction::ModifyColumnNullable {
29 table,
30 column,
31 nullable,
32 ..
33 } => write_nullable_action(f, table, column, *nullable),
34 MigrationAction::ModifyColumnDefault {
35 table,
36 column,
37 new_default,
38 ..
39 } => write_default_action(f, table, column, new_default.as_deref()),
40 MigrationAction::ModifyColumnComment {
41 table,
42 column,
43 new_comment,
44 } => write_comment_action(f, table, column, new_comment.as_deref()),
45 MigrationAction::AddConstraint { table, constraint } => {
46 write_constraint_action(f, "AddConstraint", table, constraint)
47 }
48 MigrationAction::RemoveConstraint { table, constraint } => {
49 write_constraint_action(f, "RemoveConstraint", table, constraint)
50 }
51 MigrationAction::ReplaceConstraint { table, to, .. } => {
52 write_constraint_action(f, "ReplaceConstraint", table, to)
53 }
54 MigrationAction::RenameTable { from, to } => write!(f, "RenameTable: {from} -> {to}"),
55 MigrationAction::RawSql { sql } => write_raw_sql_action(f, sql),
56 MigrationAction::DataMigration { sql, description } => {
57 write_data_migration_action(f, sql, description.as_deref())
58 }
59 MigrationAction::RemapEnumValues {
60 table,
61 column,
62 mapping,
63 } => {
64 write!(f, "RemapEnumValues: {table}.{column} [")?;
65 for (i, (old, new)) in mapping.iter().enumerate() {
66 if i > 0 {
67 write!(f, ", ")?;
68 }
69 write!(f, "{old}->{new}")?;
70 }
71 write!(f, "]")
72 }
73 }
74}
75
76fn write_nullable_action(
77 f: &mut fmt::Formatter<'_>,
78 table: &str,
79 column: &str,
80 nullable: bool,
81) -> fmt::Result {
82 let nullability = if nullable { "NULL" } else { "NOT NULL" };
83 write!(f, "ModifyColumnNullable: {table}.{column} -> {nullability}")
84}
85
86fn write_default_action(
87 f: &mut fmt::Formatter<'_>,
88 table: &str,
89 column: &str,
90 default: Option<&str>,
91) -> fmt::Result {
92 if let Some(default) = default {
93 write!(f, "ModifyColumnDefault: {table}.{column} -> {default}")
94 } else {
95 write!(f, "ModifyColumnDefault: {table}.{column} -> (none)")
96 }
97}
98
99fn write_comment_action(
100 f: &mut fmt::Formatter<'_>,
101 table: &str,
102 column: &str,
103 comment: Option<&str>,
104) -> fmt::Result {
105 if let Some(comment) = comment {
106 let display = truncate_comment(comment);
107 write!(f, "ModifyColumnComment: {table}.{column} -> '{display}'")
108 } else {
109 write!(f, "ModifyColumnComment: {table}.{column} -> (none)")
110 }
111}
112
113#[must_use]
120pub fn truncate_comment(comment: &str) -> Cow<'_, str> {
121 if comment.char_indices().nth(30).is_some() {
124 Cow::Owned(format!("{}...", truncate_chars(comment, 27)))
125 } else {
126 Cow::Borrowed(comment)
127 }
128}
129
130fn truncate_chars(s: &str, max_chars: usize) -> String {
131 s.chars().take(max_chars).collect()
132}
133
134fn write_raw_sql_action(f: &mut fmt::Formatter<'_>, sql: &str) -> fmt::Result {
135 if sql.chars().nth(50).is_some() {
136 let head = truncate_chars(sql, 47);
137 write!(f, "RawSql: {head}...")
138 } else {
139 write!(f, "RawSql: {sql}")
140 }
141}
142
143fn write_data_migration_action(
144 f: &mut fmt::Formatter<'_>,
145 sql: &DataMigrationSql,
146 description: Option<&str>,
147) -> fmt::Result {
148 match description {
149 Some(description) => write!(f, "DataMigration: {description}"),
150 None => write!(f, "DataMigration: {}", super::sql_preview(sql.postgres())),
151 }
152}
153
154fn write_constraint_action(
155 f: &mut fmt::Formatter<'_>,
156 action: &str,
157 table: &str,
158 constraint: &TableConstraint,
159) -> fmt::Result {
160 match constraint {
161 TableConstraint::PrimaryKey { .. } => write!(f, "{action}: {table}.PRIMARY KEY"),
162 TableConstraint::Unique { name, .. } => {
163 write_named_constraint(f, action, table, name.as_deref(), "UNIQUE")
164 }
165 TableConstraint::ForeignKey { name, .. } => {
166 write_named_constraint(f, action, table, name.as_deref(), "FOREIGN KEY")
167 }
168 TableConstraint::Check { name, .. } => write!(f, "{action}: {table}.{name} (CHECK)"),
169 TableConstraint::Index { name, .. } => {
170 write_named_constraint(f, action, table, name.as_deref(), "INDEX")
171 }
172 }
173}
174
175fn write_named_constraint(
176 f: &mut fmt::Formatter<'_>,
177 action: &str,
178 table: &str,
179 name: Option<&str>,
180 fallback: &str,
181) -> fmt::Result {
182 if let Some(name) = name {
183 write!(f, "{action}: {table}.{name} ({fallback})")
184 } else {
185 write!(f, "{action}: {table}.{fallback}")
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
195 use crate::action::MigrationAction;
196
197 #[test]
198 fn modify_column_default_some_format() {
199 let action = MigrationAction::ModifyColumnDefault {
201 table: "user".into(),
202 column: "status".into(),
203 new_default: Some("'active'".to_string()),
204 backfill: None,
205 };
206 assert_eq!(
207 format!("{action}"),
208 "ModifyColumnDefault: user.status -> 'active'"
209 );
210 }
211
212 #[test]
213 fn modify_column_default_none_format() {
214 let action = MigrationAction::ModifyColumnDefault {
216 table: "user".into(),
217 column: "status".into(),
218 new_default: None,
219 backfill: None,
220 };
221 assert_eq!(
222 format!("{action}"),
223 "ModifyColumnDefault: user.status -> (none)"
224 );
225 }
226
227 #[test]
228 fn remap_enum_values_format_single_mapping() {
229 let action = MigrationAction::RemapEnumValues {
234 table: "user".into(),
235 column: "status".into(),
236 mapping: vec![(1_i64, 2_i64)].into_iter().collect(),
237 };
238 assert_eq!(format!("{action}"), "RemapEnumValues: user.status [1->2]");
239 }
240
241 #[test]
242 fn remap_enum_values_format_multiple_mappings_joined() {
243 let action = MigrationAction::RemapEnumValues {
247 table: "order".into(),
248 column: "state".into(),
249 mapping: vec![(1_i64, 10_i64), (2_i64, 20_i64)].into_iter().collect(),
250 };
251 assert_eq!(
252 format!("{action}"),
253 "RemapEnumValues: order.state [1->10, 2->20]"
254 );
255 }
256
257 #[test]
264 fn truncate_comment_30_char_boundary() {
265 let s30 = "a".repeat(30);
266 assert_eq!(
267 truncate_comment(&s30),
268 s30,
269 "30-char string must be returned unchanged"
270 );
271
272 let s31 = "a".repeat(31);
273 let expected = format!("{}...", "a".repeat(27));
274 assert_eq!(
275 truncate_comment(&s31),
276 expected,
277 "31-char string must be truncated to 27 chars + '...'"
278 );
279 }
280
281 #[test]
286 fn truncate_comment_multibyte_char_count() {
287 let s30 = "é".repeat(30);
288 assert_eq!(
289 truncate_comment(&s30),
290 s30,
291 "30 multibyte chars must be returned unchanged"
292 );
293
294 let s31 = "é".repeat(31);
295 let expected = format!("{}...", "é".repeat(27));
296 assert_eq!(
297 truncate_comment(&s31),
298 expected,
299 "31 multibyte chars must be truncated by char count"
300 );
301 }
302
303 #[test]
307 fn truncate_chars_zero_returns_empty() {
308 assert_eq!(
309 truncate_chars("hi", 0),
310 "",
311 "truncate_chars with max_chars=0 must return empty string"
312 );
313 }
314
315 #[test]
319 fn truncate_chars_no_grapheme_panic() {
320 assert_eq!(
321 truncate_chars("héllo", 3),
322 "hél",
323 "truncate_chars must split by chars, not bytes"
324 );
325 }
326
327 #[test]
331 fn raw_sql_display_50_char_boundary_not_truncated() {
332 let sql50: String = "0123456789".repeat(5); let out = format!("{}", crate::MigrationAction::RawSql { sql: sql50.clone() });
334 assert_eq!(out, format!("RawSql: {sql50}"));
335 assert!(
336 !out.contains("..."),
337 "50-char SQL must NOT be truncated: {out}"
338 );
339
340 let sql51 = format!("{sql50}X"); let out51 = format!("{}", crate::MigrationAction::RawSql { sql: sql51.clone() });
342 let head: String = sql51.chars().take(47).collect();
343 assert_eq!(out51, format!("RawSql: {head}..."));
344 }
345
346 mod utf8 {
351 use crate::MigrationAction;
352 use proptest::prelude::*;
353
354 proptest! {
355 #[test]
356 fn action_display_does_not_panic_on_unicode(
357 s in proptest::collection::vec(any::<char>(), 0..100)
358 .prop_map(|v| v.into_iter().collect::<String>())
359 ) {
360 let action = MigrationAction::RawSql { sql: s };
361 let _ = format!("{action:?}");
362 let _ = format!("{action}");
363 }
364 }
365 }
366}