1use sea_query::Alias;
2
3use vespertide_core::TableDef;
4
5use super::helpers::{build_sea_column_def_with_table, quote_ident};
6use super::types::{BuiltQuery, DatabaseBackend, RawSql};
7use crate::error::QueryError;
8
9pub fn build_modify_column_comment(
12 backend: DatabaseBackend,
13 table: &str,
14 column: &str,
15 new_comment: Option<&str>,
16 current_schema: &[TableDef],
17) -> Result<Vec<BuiltQuery>, QueryError> {
18 let mut queries = Vec::new();
19
20 match backend {
21 DatabaseBackend::Postgres => {
22 let quoted_table = quote_ident(table, backend);
23 let quoted_column = quote_ident(column, backend);
24 let comment_sql = if let Some(comment) = new_comment {
25 let escaped = comment.replace('\'', "''");
27 format!("COMMENT ON COLUMN {quoted_table}.{quoted_column} IS '{escaped}'")
28 } else {
29 format!("COMMENT ON COLUMN {quoted_table}.{quoted_column} IS NULL")
30 };
31 queries.push(BuiltQuery::Raw(RawSql::uniform(comment_sql)));
32 }
33 DatabaseBackend::MySql => {
34 let table_def = current_schema
36 .iter()
37 .find(|t| t.name == table)
38 .ok_or_else(|| {
39 QueryError::SchemaError(format!("Table '{table}' not found in current schema."))
40 })?;
41
42 let column_def = table_def
43 .columns
44 .iter()
45 .find(|c| c.name == column)
46 .ok_or_else(|| {
47 QueryError::SchemaError(format!(
48 "Column '{column}' not found in table '{table}'."
49 ))
50 })?;
51
52 let mut modified_col_def = column_def.clone();
54 modified_col_def.comment = new_comment.map(std::string::ToString::to_string);
55
56 let sea_col = build_sea_column_def_with_table(backend, table, &modified_col_def);
58
59 let stmt = sea_query::Table::alter()
61 .table(Alias::new(table))
62 .modify_column(sea_col)
63 .to_owned();
64
65 let base_sql = super::helpers::build_schema_statement(&stmt, backend);
67
68 let final_sql = if let Some(comment) = modified_col_def.comment.as_deref() {
70 let escaped = comment.replace('\'', "''");
71 format!("{base_sql} COMMENT '{escaped}'")
72 } else {
73 base_sql
74 };
75
76 queries.push(BuiltQuery::Raw(RawSql::uniform(final_sql)));
77 }
78 DatabaseBackend::Sqlite => {
79 }
84 }
85
86 Ok(queries)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::test_support::col_n as col;
93 use insta::{assert_snapshot, with_settings};
94 use rstest::rstest;
95 use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint};
96
97 fn table_def(
98 name: &str,
99 columns: Vec<ColumnDef>,
100 constraints: Vec<TableConstraint>,
101 ) -> TableDef {
102 TableDef {
103 name: name.into(),
104 description: None,
105 columns,
106 constraints,
107 }
108 }
109
110 #[rstest]
111 #[case::postgres_set_comment(DatabaseBackend::Postgres, Some("User email address"))]
112 #[case::postgres_drop_comment(DatabaseBackend::Postgres, None)]
113 #[case::mysql_set_comment(DatabaseBackend::MySql, Some("User email address"))]
114 #[case::mysql_drop_comment(DatabaseBackend::MySql, None)]
115 #[case::sqlite_set_comment(DatabaseBackend::Sqlite, Some("User email address"))]
116 #[case::sqlite_drop_comment(DatabaseBackend::Sqlite, None)]
117 fn test_build_modify_column_comment(
118 #[case] backend: DatabaseBackend,
119 #[case] new_comment: Option<&str>,
120 ) {
121 let schema = vec![table_def(
122 "users",
123 vec![
124 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
125 col("email", ColumnType::Simple(SimpleColumnType::Text), true),
126 ],
127 vec![],
128 )];
129
130 let result = build_modify_column_comment(backend, "users", "email", new_comment, &schema);
131 assert!(result.is_ok());
132 let queries = result.unwrap();
133 let sql = queries
134 .iter()
135 .map(|q| q.build(backend))
136 .collect::<Vec<String>>()
137 .join("\n");
138
139 let suffix = format!(
140 "{}_{}_users",
141 match backend {
142 DatabaseBackend::Postgres => "postgres",
143 DatabaseBackend::MySql => "mysql",
144 DatabaseBackend::Sqlite => "sqlite",
145 },
146 if new_comment.is_some() {
147 "set_comment"
148 } else {
149 "drop_comment"
150 }
151 );
152
153 with_settings!({ snapshot_suffix => suffix }, {
154 assert_snapshot!(sql);
155 });
156 }
157
158 #[rstest]
160 #[case::postgres_comment_with_quotes(DatabaseBackend::Postgres)]
161 #[case::mysql_comment_with_quotes(DatabaseBackend::MySql)]
162 #[case::sqlite_comment_with_quotes(DatabaseBackend::Sqlite)]
163 fn test_comment_with_quotes(#[case] backend: DatabaseBackend) {
164 let schema = vec![table_def(
165 "users",
166 vec![col(
167 "email",
168 ColumnType::Simple(SimpleColumnType::Text),
169 true,
170 )],
171 vec![],
172 )];
173
174 let result = build_modify_column_comment(
175 backend,
176 "users",
177 "email",
178 Some("User's email address"),
179 &schema,
180 );
181 assert!(result.is_ok());
182 let queries = result.unwrap();
183 let sql = queries
184 .iter()
185 .map(|q| q.build(backend))
186 .collect::<Vec<String>>()
187 .join("\n");
188
189 if backend != DatabaseBackend::Sqlite {
191 assert!(
192 sql.contains("User''s email address"),
193 "Should escape single quotes"
194 );
195 }
196
197 let suffix = format!(
198 "{}_comment_with_quotes",
199 match backend {
200 DatabaseBackend::Postgres => "postgres",
201 DatabaseBackend::MySql => "mysql",
202 DatabaseBackend::Sqlite => "sqlite",
203 }
204 );
205
206 with_settings!({ snapshot_suffix => suffix }, {
207 assert_snapshot!(sql);
208 });
209 }
210
211 #[rstest]
213 #[case::postgres_table_not_found(DatabaseBackend::Postgres)]
214 #[case::mysql_table_not_found(DatabaseBackend::MySql)]
215 #[case::sqlite_table_not_found(DatabaseBackend::Sqlite)]
216 fn test_table_not_found(#[case] backend: DatabaseBackend) {
217 if backend == DatabaseBackend::Postgres || backend == DatabaseBackend::Sqlite {
219 return;
220 }
221
222 let result = build_modify_column_comment(backend, "users", "email", Some("comment"), &[]);
223 assert!(result.is_err());
224 let err_msg = result.unwrap_err().to_string();
225 assert!(err_msg.contains("Table 'users' not found"));
226 }
227
228 #[rstest]
230 #[case::postgres_column_not_found(DatabaseBackend::Postgres)]
231 #[case::mysql_column_not_found(DatabaseBackend::MySql)]
232 #[case::sqlite_column_not_found(DatabaseBackend::Sqlite)]
233 fn test_column_not_found(#[case] backend: DatabaseBackend) {
234 if backend == DatabaseBackend::Postgres || backend == DatabaseBackend::Sqlite {
236 return;
237 }
238
239 let schema = vec![table_def(
240 "users",
241 vec![col(
242 "id",
243 ColumnType::Simple(SimpleColumnType::Integer),
244 false,
245 )],
246 vec![],
247 )];
248
249 let result =
250 build_modify_column_comment(backend, "users", "email", Some("comment"), &schema);
251 assert!(result.is_err());
252 let err_msg = result.unwrap_err().to_string();
253 assert!(err_msg.contains("Column 'email' not found"));
254 }
255
256 #[rstest]
258 #[case::postgres_long_comment(DatabaseBackend::Postgres)]
259 #[case::mysql_long_comment(DatabaseBackend::MySql)]
260 #[case::sqlite_long_comment(DatabaseBackend::Sqlite)]
261 fn test_long_comment(#[case] backend: DatabaseBackend) {
262 let schema = vec![table_def(
263 "users",
264 vec![col("bio", ColumnType::Simple(SimpleColumnType::Text), true)],
265 vec![],
266 )];
267
268 let long_comment = "This is a very long comment that describes the bio field in great detail. It contains multiple sentences and provides thorough documentation for this column.";
269
270 let result =
271 build_modify_column_comment(backend, "users", "bio", Some(long_comment), &schema);
272 assert!(result.is_ok());
273 let queries = result.unwrap();
274 let sql = queries
275 .iter()
276 .map(|q| q.build(backend))
277 .collect::<Vec<String>>()
278 .join("\n");
279
280 let suffix = format!(
281 "{}_long_comment",
282 match backend {
283 DatabaseBackend::Postgres => "postgres",
284 DatabaseBackend::MySql => "mysql",
285 DatabaseBackend::Sqlite => "sqlite",
286 }
287 );
288
289 with_settings!({ snapshot_suffix => suffix }, {
290 assert_snapshot!(sql);
291 });
292 }
293
294 #[rstest]
296 #[case::postgres_preserves_properties(DatabaseBackend::Postgres)]
297 #[case::mysql_preserves_properties(DatabaseBackend::MySql)]
298 #[case::sqlite_preserves_properties(DatabaseBackend::Sqlite)]
299 fn test_preserves_column_properties(#[case] backend: DatabaseBackend) {
300 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
301 email_col.default = Some("'default@example.com'".into());
302
303 let schema = vec![table_def(
304 "users",
305 vec![
306 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
307 email_col,
308 ],
309 vec![],
310 )];
311
312 let result = build_modify_column_comment(
313 backend,
314 "users",
315 "email",
316 Some("User email address"),
317 &schema,
318 );
319 assert!(result.is_ok());
320 let queries = result.unwrap();
321 let sql = queries
322 .iter()
323 .map(|q| q.build(backend))
324 .collect::<Vec<String>>()
325 .join("\n");
326
327 if backend == DatabaseBackend::MySql {
329 assert!(sql.contains("DEFAULT"), "Should preserve DEFAULT clause");
330 }
331
332 let suffix = format!(
333 "{}_preserves_properties",
334 match backend {
335 DatabaseBackend::Postgres => "postgres",
336 DatabaseBackend::MySql => "mysql",
337 DatabaseBackend::Sqlite => "sqlite",
338 }
339 );
340
341 with_settings!({ snapshot_suffix => suffix }, {
342 assert_snapshot!(sql);
343 });
344 }
345
346 #[rstest]
348 #[case::postgres_change_comment(DatabaseBackend::Postgres)]
349 #[case::mysql_change_comment(DatabaseBackend::MySql)]
350 #[case::sqlite_change_comment(DatabaseBackend::Sqlite)]
351 fn test_change_comment(#[case] backend: DatabaseBackend) {
352 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
353 email_col.comment = Some("Old comment".into());
354
355 let schema = vec![table_def(
356 "users",
357 vec![
358 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
359 email_col,
360 ],
361 vec![],
362 )];
363
364 let result =
365 build_modify_column_comment(backend, "users", "email", Some("New comment"), &schema);
366 assert!(result.is_ok());
367 let queries = result.unwrap();
368 let sql = queries
369 .iter()
370 .map(|q| q.build(backend))
371 .collect::<Vec<String>>()
372 .join("\n");
373
374 let suffix = format!(
375 "{}_change_comment",
376 match backend {
377 DatabaseBackend::Postgres => "postgres",
378 DatabaseBackend::MySql => "mysql",
379 DatabaseBackend::Sqlite => "sqlite",
380 }
381 );
382
383 with_settings!({ snapshot_suffix => suffix }, {
384 assert_snapshot!(sql);
385 });
386 }
387
388 #[rstest]
390 #[case::postgres_drop_existing_comment(DatabaseBackend::Postgres)]
391 #[case::mysql_drop_existing_comment(DatabaseBackend::MySql)]
392 #[case::sqlite_drop_existing_comment(DatabaseBackend::Sqlite)]
393 fn test_drop_existing_comment(#[case] backend: DatabaseBackend) {
394 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
395 email_col.comment = Some("Existing comment".into());
396
397 let schema = vec![table_def(
398 "users",
399 vec![
400 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
401 email_col,
402 ],
403 vec![],
404 )];
405
406 let result = build_modify_column_comment(
407 backend, "users", "email", None, &schema,
409 );
410 assert!(result.is_ok());
411 let queries = result.unwrap();
412 let sql = queries
413 .iter()
414 .map(|q| q.build(backend))
415 .collect::<Vec<String>>()
416 .join("\n");
417
418 let suffix = format!(
419 "{}_drop_existing_comment",
420 match backend {
421 DatabaseBackend::Postgres => "postgres",
422 DatabaseBackend::MySql => "mysql",
423 DatabaseBackend::Sqlite => "sqlite",
424 }
425 );
426
427 with_settings!({ snapshot_suffix => suffix }, {
428 assert_snapshot!(sql);
429 });
430 }
431
432 #[rstest]
434 #[case::postgres_integer_column(
435 DatabaseBackend::Postgres,
436 SimpleColumnType::Integer,
437 "Auto-increment ID"
438 )]
439 #[case::mysql_integer_column(
440 DatabaseBackend::MySql,
441 SimpleColumnType::Integer,
442 "Auto-increment ID"
443 )]
444 #[case::sqlite_integer_column(
445 DatabaseBackend::Sqlite,
446 SimpleColumnType::Integer,
447 "Auto-increment ID"
448 )]
449 #[case::postgres_boolean_column(
450 DatabaseBackend::Postgres,
451 SimpleColumnType::Boolean,
452 "Is user active"
453 )]
454 #[case::mysql_boolean_column(
455 DatabaseBackend::MySql,
456 SimpleColumnType::Boolean,
457 "Is user active"
458 )]
459 #[case::sqlite_boolean_column(
460 DatabaseBackend::Sqlite,
461 SimpleColumnType::Boolean,
462 "Is user active"
463 )]
464 #[case::postgres_timestamp_column(
465 DatabaseBackend::Postgres,
466 SimpleColumnType::Timestamp,
467 "Creation timestamp"
468 )]
469 #[case::mysql_timestamp_column(
470 DatabaseBackend::MySql,
471 SimpleColumnType::Timestamp,
472 "Creation timestamp"
473 )]
474 #[case::sqlite_timestamp_column(
475 DatabaseBackend::Sqlite,
476 SimpleColumnType::Timestamp,
477 "Creation timestamp"
478 )]
479 fn test_comment_on_different_types(
480 #[case] backend: DatabaseBackend,
481 #[case] column_type: SimpleColumnType,
482 #[case] comment: &str,
483 ) {
484 let schema = vec![table_def(
485 "data",
486 vec![col("field", ColumnType::Simple(column_type), false)],
487 vec![],
488 )];
489
490 let result = build_modify_column_comment(backend, "data", "field", Some(comment), &schema);
491 assert!(result.is_ok());
492 let queries = result.unwrap();
493 let sql = queries
494 .iter()
495 .map(|q| q.build(backend))
496 .collect::<Vec<String>>()
497 .join("\n");
498
499 let type_name = format!("{column_type:?}").to_lowercase();
500 let suffix = format!(
501 "{}_{}_comment",
502 match backend {
503 DatabaseBackend::Postgres => "postgres",
504 DatabaseBackend::MySql => "mysql",
505 DatabaseBackend::Sqlite => "sqlite",
506 },
507 type_name
508 );
509
510 with_settings!({ snapshot_suffix => suffix }, {
511 assert_snapshot!(sql);
512 });
513 }
514
515 #[rstest]
520 #[case::postgres_set(DatabaseBackend::Postgres, Some("hello"))]
521 #[case::postgres_drop(DatabaseBackend::Postgres, None)]
522 #[case::mysql_set(DatabaseBackend::MySql, Some("hello"))]
523 #[case::mysql_drop(DatabaseBackend::MySql, None)]
524 #[case::sqlite_set(DatabaseBackend::Sqlite, Some("hello"))]
525 #[case::sqlite_drop(DatabaseBackend::Sqlite, None)]
526 fn modify_column_comment_emits_exact_literal(
527 #[case] backend: DatabaseBackend,
528 #[case] new_comment: Option<&str>,
529 ) {
530 let schema = vec![table_def(
531 "users",
532 vec![col(
533 "email",
534 ColumnType::Simple(SimpleColumnType::Text),
535 false,
536 )],
537 vec![],
538 )];
539
540 let queries = build_modify_column_comment(backend, "users", "email", new_comment, &schema)
541 .expect("build_modify_column_comment should succeed");
542 let sql = queries
543 .iter()
544 .map(|q| q.build(backend))
545 .collect::<Vec<String>>()
546 .join("\n");
547
548 match (backend, new_comment) {
549 (DatabaseBackend::Sqlite, _) => {
550 assert!(
551 queries.is_empty(),
552 "SQLite does not support column comments; expected no \
553 emitted SQL, got: {sql}"
554 );
555 }
556 (DatabaseBackend::Postgres, Some(_)) => {
557 assert!(
558 sql.contains("IS 'hello'"),
559 "Postgres set-comment must emit exact `IS 'hello'`; got: {sql}"
560 );
561 assert!(
562 sql.contains("COMMENT ON COLUMN \"users\".\"email\" IS 'hello'"),
563 "Postgres set-comment must emit the full `COMMENT ON \
564 COLUMN \"users\".\"email\" IS 'hello'` statement; got: {sql}"
565 );
566 }
567 (DatabaseBackend::Postgres, None) => {
568 assert!(
569 sql.contains("IS NULL"),
570 "Postgres drop-comment must emit exact `IS NULL`; got: {sql}"
571 );
572 assert!(
573 !sql.contains("IS ''"),
574 "Postgres drop-comment must not emit empty-string literal \
575 `IS ''`; got: {sql}"
576 );
577 }
578 (DatabaseBackend::MySql, Some(_)) => {
579 assert!(
580 sql.contains("COMMENT 'hello'"),
581 "MySQL set-comment must emit exact `COMMENT 'hello'`; got: {sql}"
582 );
583 assert!(
584 !sql.contains("COMMENT ''"),
585 "MySQL set-comment must not emit empty-string literal \
586 `COMMENT ''`; got: {sql}"
587 );
588 assert!(
589 sql.contains("MODIFY COLUMN"),
590 "MySQL set-comment must use ALTER TABLE ... MODIFY COLUMN; \
591 got: {sql}"
592 );
593 }
594 (DatabaseBackend::MySql, None) => {
595 assert!(
596 !sql.contains("COMMENT '"),
597 "MySQL drop-comment must not append any `COMMENT '...'` \
598 clause; got: {sql}"
599 );
600 assert!(
601 sql.contains("MODIFY COLUMN"),
602 "MySQL drop-comment must still emit MODIFY COLUMN; got: {sql}"
603 );
604 }
605 }
606 }
607
608 #[rstest]
610 #[case::postgres_not_null_column(DatabaseBackend::Postgres)]
611 #[case::mysql_not_null_column(DatabaseBackend::MySql)]
612 #[case::sqlite_not_null_column(DatabaseBackend::Sqlite)]
613 fn test_comment_on_not_null_column(#[case] backend: DatabaseBackend) {
614 let schema = vec![table_def(
615 "users",
616 vec![col(
617 "username",
618 ColumnType::Simple(SimpleColumnType::Text),
619 false,
620 )],
621 vec![],
622 )];
623
624 let result = build_modify_column_comment(
625 backend,
626 "users",
627 "username",
628 Some("Required username"),
629 &schema,
630 );
631 assert!(result.is_ok());
632 let queries = result.unwrap();
633 let sql = queries
634 .iter()
635 .map(|q| q.build(backend))
636 .collect::<Vec<String>>()
637 .join("\n");
638
639 let suffix = format!(
640 "{}_not_null_column",
641 match backend {
642 DatabaseBackend::Postgres => "postgres",
643 DatabaseBackend::MySql => "mysql",
644 DatabaseBackend::Sqlite => "sqlite",
645 }
646 );
647
648 with_settings!({ snapshot_suffix => suffix }, {
649 assert_snapshot!(sql);
650 });
651 }
652}