Skip to main content

vespertide_query/sql/
modify_column_comment.rs

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
11/// Build SQL for changing column comment.
12/// Note: `SQLite` does not support column comments natively.
13pub 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                // Escape single quotes in comment
28                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            // MySQL requires the full column definition in MODIFY COLUMN to change comment
37            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            // Build the full column definition with updated comment
46            let mut modified_col_def = column_def.clone();
47            modified_col_def.comment = new_comment.map(std::string::ToString::to_string);
48
49            // Build base ALTER TABLE statement using sea-query for type/nullable/default
50            let sea_col = build_sea_column_def_with_table(backend, table, &modified_col_def);
51
52            // Build the ALTER TABLE ... MODIFY COLUMN statement
53            let stmt = sea_query::Table::alter()
54                .table(Alias::new(table))
55                .modify_column(sea_col)
56                .to_owned();
57
58            // Get the base SQL from sea-query
59            let base_sql = super::helpers::build_schema_statement(&stmt, backend);
60
61            // Add COMMENT clause if needed (sea-query doesn't support COMMENT)
62            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            // SQLite doesn't support column comments
73            // We could store the comment in a separate table or just ignore it
74            // For now, we'll skip this operation for SQLite since it doesn't affect the schema
75            // Just update the internal schema representation (handled by apply.rs)
76        }
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    /// Test comment with quotes escaping
131    #[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        // Postgres and MySQL should escape quotes, SQLite returns empty
158        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    /// Test table not found error
173    #[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        // Postgres and SQLite don't need schema lookup, so skip this test for them
179        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    /// Test column not found error
190    #[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        // Postgres and SQLite don't need schema lookup, so skip this test for them
196        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    /// Test with long comment
218    #[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    /// Test preserves column properties when modifying comment
245    #[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        // MySQL should preserve the default value in the MODIFY COLUMN statement
274        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    /// Test changing comment from one value to another
286    #[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    /// Test dropping existing comment
317    #[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, // Drop comment
336            &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    /// Test with different column types
350    #[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    /// Mutant target: `modify_column_comment.rs` line 54 — the MySQL
421    /// branch `comment: new_comment.map(...)` plus the surrounding emit
422    /// path that hand-appends `COMMENT '...'`. Pins EXACT `'hello'`
423    /// literal so any mutation blanking the comment is caught.
424    #[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    /// Test with NOT NULL column
510    #[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}