1use sea_query::Alias;
2
3use vespertide_core::TableDef;
4
5use super::helpers::{
6 build_sea_column_def_with_table, quote_ident, require_column_in_table, require_table_in_schema,
7};
8use super::types::{BuiltQuery, DatabaseBackend, RawSql};
9use crate::error::QueryError;
10
11pub fn build_modify_column_comment(
14 backend: DatabaseBackend,
15 table: &str,
16 column: &str,
17 new_comment: Option<&str>,
18 current_schema: &[TableDef],
19) -> Result<Vec<BuiltQuery>, QueryError> {
20 let mut queries = Vec::new();
21
22 match backend {
23 DatabaseBackend::Postgres => {
24 let quoted_table = quote_ident(table, backend);
25 let quoted_column = quote_ident(column, backend);
26 let comment_sql = if let Some(comment) = new_comment {
27 let escaped = vespertide_core::escape_sql_string_literal(comment);
29 format!("COMMENT ON COLUMN {quoted_table}.{quoted_column} IS '{escaped}'")
30 } else {
31 format!("COMMENT ON COLUMN {quoted_table}.{quoted_column} IS NULL")
32 };
33 queries.push(BuiltQuery::Raw(RawSql::uniform(comment_sql)));
34 }
35 DatabaseBackend::MySql => {
36 let table_def = require_table_in_schema(
38 current_schema,
39 table,
40 "MySQL requires current schema information to modify column comments",
41 )?;
42
43 let column_def = require_column_in_table(table_def, column)?;
44
45 let mut modified_col_def = column_def.clone();
47 modified_col_def.comment = new_comment.map(std::string::ToString::to_string);
48
49 let sea_col = build_sea_column_def_with_table(backend, table, &modified_col_def);
51
52 let stmt = sea_query::Table::alter()
54 .table(Alias::new(table))
55 .modify_column(sea_col)
56 .to_owned();
57
58 let base_sql = super::helpers::build_schema_statement(&stmt, backend);
60
61 let final_sql = if let Some(comment) = modified_col_def.comment.as_deref() {
63 let escaped = vespertide_core::escape_sql_string_literal(comment);
64 format!("{base_sql} COMMENT '{escaped}'")
65 } else {
66 base_sql
67 };
68
69 queries.push(BuiltQuery::Raw(RawSql::uniform(final_sql)));
70 }
71 DatabaseBackend::Sqlite => {
72 }
77 }
78
79 Ok(queries)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use crate::test_support::{backend_tag, col_n as col, joined_sql, table_def};
86 use insta::{assert_snapshot, with_settings};
87 use rstest::rstest;
88 use vespertide_core::{ColumnType, SimpleColumnType};
89
90 #[rstest]
91 #[case::postgres_set_comment(DatabaseBackend::Postgres, Some("User email address"))]
92 #[case::postgres_drop_comment(DatabaseBackend::Postgres, None)]
93 #[case::mysql_set_comment(DatabaseBackend::MySql, Some("User email address"))]
94 #[case::mysql_drop_comment(DatabaseBackend::MySql, None)]
95 #[case::sqlite_set_comment(DatabaseBackend::Sqlite, Some("User email address"))]
96 #[case::sqlite_drop_comment(DatabaseBackend::Sqlite, None)]
97 fn test_build_modify_column_comment(
98 #[case] backend: DatabaseBackend,
99 #[case] new_comment: Option<&str>,
100 ) {
101 let schema = vec![table_def(
102 "users",
103 vec![
104 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
105 col("email", ColumnType::Simple(SimpleColumnType::Text), true),
106 ],
107 vec![],
108 )];
109
110 let result = build_modify_column_comment(backend, "users", "email", new_comment, &schema);
111 assert!(result.is_ok());
112 let queries = result.unwrap();
113 let sql = joined_sql(backend, &queries);
114
115 let suffix = format!(
116 "{}_{}_users",
117 backend_tag(backend),
118 if new_comment.is_some() {
119 "set_comment"
120 } else {
121 "drop_comment"
122 }
123 );
124
125 with_settings!({ snapshot_suffix => suffix }, {
126 assert_snapshot!(sql);
127 });
128 }
129
130 #[rstest]
132 #[case::postgres_comment_with_quotes(DatabaseBackend::Postgres)]
133 #[case::mysql_comment_with_quotes(DatabaseBackend::MySql)]
134 #[case::sqlite_comment_with_quotes(DatabaseBackend::Sqlite)]
135 fn test_comment_with_quotes(#[case] backend: DatabaseBackend) {
136 let schema = vec![table_def(
137 "users",
138 vec![col(
139 "email",
140 ColumnType::Simple(SimpleColumnType::Text),
141 true,
142 )],
143 vec![],
144 )];
145
146 let result = build_modify_column_comment(
147 backend,
148 "users",
149 "email",
150 Some("User's email address"),
151 &schema,
152 );
153 assert!(result.is_ok());
154 let queries = result.unwrap();
155 let sql = joined_sql(backend, &queries);
156
157 if backend != DatabaseBackend::Sqlite {
159 assert!(
160 sql.contains("User''s email address"),
161 "Should escape single quotes"
162 );
163 }
164
165 let suffix = format!("{}_comment_with_quotes", backend_tag(backend));
166
167 with_settings!({ snapshot_suffix => suffix }, {
168 assert_snapshot!(sql);
169 });
170 }
171
172 #[rstest]
174 #[case::postgres_table_not_found(DatabaseBackend::Postgres)]
175 #[case::mysql_table_not_found(DatabaseBackend::MySql)]
176 #[case::sqlite_table_not_found(DatabaseBackend::Sqlite)]
177 fn test_table_not_found(#[case] backend: DatabaseBackend) {
178 if backend == DatabaseBackend::Postgres || backend == DatabaseBackend::Sqlite {
180 return;
181 }
182
183 let result = build_modify_column_comment(backend, "users", "email", Some("comment"), &[]);
184 assert!(result.is_err());
185 let err_msg = result.unwrap_err().to_string();
186 assert!(err_msg.contains("Table 'users' not found"));
187 }
188
189 #[rstest]
191 #[case::postgres_column_not_found(DatabaseBackend::Postgres)]
192 #[case::mysql_column_not_found(DatabaseBackend::MySql)]
193 #[case::sqlite_column_not_found(DatabaseBackend::Sqlite)]
194 fn test_column_not_found(#[case] backend: DatabaseBackend) {
195 if backend == DatabaseBackend::Postgres || backend == DatabaseBackend::Sqlite {
197 return;
198 }
199
200 let schema = vec![table_def(
201 "users",
202 vec![col(
203 "id",
204 ColumnType::Simple(SimpleColumnType::Integer),
205 false,
206 )],
207 vec![],
208 )];
209
210 let result =
211 build_modify_column_comment(backend, "users", "email", Some("comment"), &schema);
212 assert!(result.is_err());
213 let err_msg = result.unwrap_err().to_string();
214 assert!(err_msg.contains("Column 'email' not found"));
215 }
216
217 #[rstest]
219 #[case::postgres_long_comment(DatabaseBackend::Postgres)]
220 #[case::mysql_long_comment(DatabaseBackend::MySql)]
221 #[case::sqlite_long_comment(DatabaseBackend::Sqlite)]
222 fn test_long_comment(#[case] backend: DatabaseBackend) {
223 let schema = vec![table_def(
224 "users",
225 vec![col("bio", ColumnType::Simple(SimpleColumnType::Text), true)],
226 vec![],
227 )];
228
229 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.";
230
231 let result =
232 build_modify_column_comment(backend, "users", "bio", Some(long_comment), &schema);
233 assert!(result.is_ok());
234 let queries = result.unwrap();
235 let sql = joined_sql(backend, &queries);
236
237 let suffix = format!("{}_long_comment", backend_tag(backend));
238
239 with_settings!({ snapshot_suffix => suffix }, {
240 assert_snapshot!(sql);
241 });
242 }
243
244 #[rstest]
246 #[case::postgres_preserves_properties(DatabaseBackend::Postgres)]
247 #[case::mysql_preserves_properties(DatabaseBackend::MySql)]
248 #[case::sqlite_preserves_properties(DatabaseBackend::Sqlite)]
249 fn test_preserves_column_properties(#[case] backend: DatabaseBackend) {
250 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
251 email_col.default = Some("'default@example.com'".into());
252
253 let schema = vec![table_def(
254 "users",
255 vec![
256 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
257 email_col,
258 ],
259 vec![],
260 )];
261
262 let result = build_modify_column_comment(
263 backend,
264 "users",
265 "email",
266 Some("User email address"),
267 &schema,
268 );
269 assert!(result.is_ok());
270 let queries = result.unwrap();
271 let sql = joined_sql(backend, &queries);
272
273 if backend == DatabaseBackend::MySql {
275 assert!(sql.contains("DEFAULT"), "Should preserve DEFAULT clause");
276 }
277
278 let suffix = format!("{}_preserves_properties", backend_tag(backend));
279
280 with_settings!({ snapshot_suffix => suffix }, {
281 assert_snapshot!(sql);
282 });
283 }
284
285 #[rstest]
287 #[case::postgres_change_comment(DatabaseBackend::Postgres)]
288 #[case::mysql_change_comment(DatabaseBackend::MySql)]
289 #[case::sqlite_change_comment(DatabaseBackend::Sqlite)]
290 fn test_change_comment(#[case] backend: DatabaseBackend) {
291 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
292 email_col.comment = Some("Old comment".into());
293
294 let schema = vec![table_def(
295 "users",
296 vec![
297 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
298 email_col,
299 ],
300 vec![],
301 )];
302
303 let result =
304 build_modify_column_comment(backend, "users", "email", Some("New comment"), &schema);
305 assert!(result.is_ok());
306 let queries = result.unwrap();
307 let sql = joined_sql(backend, &queries);
308
309 let suffix = format!("{}_change_comment", backend_tag(backend));
310
311 with_settings!({ snapshot_suffix => suffix }, {
312 assert_snapshot!(sql);
313 });
314 }
315
316 #[rstest]
318 #[case::postgres_drop_existing_comment(DatabaseBackend::Postgres)]
319 #[case::mysql_drop_existing_comment(DatabaseBackend::MySql)]
320 #[case::sqlite_drop_existing_comment(DatabaseBackend::Sqlite)]
321 fn test_drop_existing_comment(#[case] backend: DatabaseBackend) {
322 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
323 email_col.comment = Some("Existing comment".into());
324
325 let schema = vec![table_def(
326 "users",
327 vec![
328 col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
329 email_col,
330 ],
331 vec![],
332 )];
333
334 let result = build_modify_column_comment(
335 backend, "users", "email", None, &schema,
337 );
338 assert!(result.is_ok());
339 let queries = result.unwrap();
340 let sql = joined_sql(backend, &queries);
341
342 let suffix = format!("{}_drop_existing_comment", backend_tag(backend));
343
344 with_settings!({ snapshot_suffix => suffix }, {
345 assert_snapshot!(sql);
346 });
347 }
348
349 #[rstest]
351 #[case::postgres_integer_column(
352 DatabaseBackend::Postgres,
353 SimpleColumnType::Integer,
354 "Auto-increment ID"
355 )]
356 #[case::mysql_integer_column(
357 DatabaseBackend::MySql,
358 SimpleColumnType::Integer,
359 "Auto-increment ID"
360 )]
361 #[case::sqlite_integer_column(
362 DatabaseBackend::Sqlite,
363 SimpleColumnType::Integer,
364 "Auto-increment ID"
365 )]
366 #[case::postgres_boolean_column(
367 DatabaseBackend::Postgres,
368 SimpleColumnType::Boolean,
369 "Is user active"
370 )]
371 #[case::mysql_boolean_column(
372 DatabaseBackend::MySql,
373 SimpleColumnType::Boolean,
374 "Is user active"
375 )]
376 #[case::sqlite_boolean_column(
377 DatabaseBackend::Sqlite,
378 SimpleColumnType::Boolean,
379 "Is user active"
380 )]
381 #[case::postgres_timestamp_column(
382 DatabaseBackend::Postgres,
383 SimpleColumnType::Timestamp,
384 "Creation timestamp"
385 )]
386 #[case::mysql_timestamp_column(
387 DatabaseBackend::MySql,
388 SimpleColumnType::Timestamp,
389 "Creation timestamp"
390 )]
391 #[case::sqlite_timestamp_column(
392 DatabaseBackend::Sqlite,
393 SimpleColumnType::Timestamp,
394 "Creation timestamp"
395 )]
396 fn test_comment_on_different_types(
397 #[case] backend: DatabaseBackend,
398 #[case] column_type: SimpleColumnType,
399 #[case] comment: &str,
400 ) {
401 let schema = vec![table_def(
402 "data",
403 vec![col("field", ColumnType::Simple(column_type), false)],
404 vec![],
405 )];
406
407 let result = build_modify_column_comment(backend, "data", "field", Some(comment), &schema);
408 assert!(result.is_ok());
409 let queries = result.unwrap();
410 let sql = joined_sql(backend, &queries);
411
412 let type_name = format!("{column_type:?}").to_lowercase();
413 let suffix = format!("{}_{}_comment", backend_tag(backend), type_name);
414
415 with_settings!({ snapshot_suffix => suffix }, {
416 assert_snapshot!(sql);
417 });
418 }
419
420 #[rstest]
425 #[case::postgres_set(DatabaseBackend::Postgres, Some("hello"))]
426 #[case::postgres_drop(DatabaseBackend::Postgres, None)]
427 #[case::mysql_set(DatabaseBackend::MySql, Some("hello"))]
428 #[case::mysql_drop(DatabaseBackend::MySql, None)]
429 #[case::sqlite_set(DatabaseBackend::Sqlite, Some("hello"))]
430 #[case::sqlite_drop(DatabaseBackend::Sqlite, None)]
431 fn modify_column_comment_emits_exact_literal(
432 #[case] backend: DatabaseBackend,
433 #[case] new_comment: Option<&str>,
434 ) {
435 let schema = vec![table_def(
436 "users",
437 vec![col(
438 "email",
439 ColumnType::Simple(SimpleColumnType::Text),
440 false,
441 )],
442 vec![],
443 )];
444
445 let queries = build_modify_column_comment(backend, "users", "email", new_comment, &schema)
446 .expect("build_modify_column_comment should succeed");
447 let sql = joined_sql(backend, &queries);
448
449 match (backend, new_comment) {
450 (DatabaseBackend::Sqlite, _) => {
451 assert!(
452 queries.is_empty(),
453 "SQLite does not support column comments; expected no \
454 emitted SQL, got: {sql}"
455 );
456 }
457 (DatabaseBackend::Postgres, Some(_)) => {
458 assert!(
459 sql.contains("IS 'hello'"),
460 "Postgres set-comment must emit exact `IS 'hello'`; got: {sql}"
461 );
462 assert!(
463 sql.contains("COMMENT ON COLUMN \"users\".\"email\" IS 'hello'"),
464 "Postgres set-comment must emit the full `COMMENT ON \
465 COLUMN \"users\".\"email\" IS 'hello'` statement; got: {sql}"
466 );
467 }
468 (DatabaseBackend::Postgres, None) => {
469 assert!(
470 sql.contains("IS NULL"),
471 "Postgres drop-comment must emit exact `IS NULL`; got: {sql}"
472 );
473 assert!(
474 !sql.contains("IS ''"),
475 "Postgres drop-comment must not emit empty-string literal \
476 `IS ''`; got: {sql}"
477 );
478 }
479 (DatabaseBackend::MySql, Some(_)) => {
480 assert!(
481 sql.contains("COMMENT 'hello'"),
482 "MySQL set-comment must emit exact `COMMENT 'hello'`; got: {sql}"
483 );
484 assert!(
485 !sql.contains("COMMENT ''"),
486 "MySQL set-comment must not emit empty-string literal \
487 `COMMENT ''`; got: {sql}"
488 );
489 assert!(
490 sql.contains("MODIFY COLUMN"),
491 "MySQL set-comment must use ALTER TABLE ... MODIFY COLUMN; \
492 got: {sql}"
493 );
494 }
495 (DatabaseBackend::MySql, None) => {
496 assert!(
497 !sql.contains("COMMENT '"),
498 "MySQL drop-comment must not append any `COMMENT '...'` \
499 clause; got: {sql}"
500 );
501 assert!(
502 sql.contains("MODIFY COLUMN"),
503 "MySQL drop-comment must still emit MODIFY COLUMN; got: {sql}"
504 );
505 }
506 }
507 }
508
509 #[rstest]
511 #[case::postgres_not_null_column(DatabaseBackend::Postgres)]
512 #[case::mysql_not_null_column(DatabaseBackend::MySql)]
513 #[case::sqlite_not_null_column(DatabaseBackend::Sqlite)]
514 fn test_comment_on_not_null_column(#[case] backend: DatabaseBackend) {
515 let schema = vec![table_def(
516 "users",
517 vec![col(
518 "username",
519 ColumnType::Simple(SimpleColumnType::Text),
520 false,
521 )],
522 vec![],
523 )];
524
525 let result = build_modify_column_comment(
526 backend,
527 "users",
528 "username",
529 Some("Required username"),
530 &schema,
531 );
532 assert!(result.is_ok());
533 let queries = result.unwrap();
534 let sql = joined_sql(backend, &queries);
535
536 let suffix = format!("{}_not_null_column", backend_tag(backend));
537
538 with_settings!({ snapshot_suffix => suffix }, {
539 assert_snapshot!(sql);
540 });
541 }
542}