Skip to main content

vespertide_query/sql/
modify_column_comment.rs

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
9/// Build SQL for changing column comment.
10/// Note: `SQLite` does not support column comments natively.
11pub 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                // Escape single quotes in comment
26                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            // MySQL requires the full column definition in MODIFY COLUMN to change comment
35            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            // Build the full column definition with updated comment
53            let mut modified_col_def = column_def.clone();
54            modified_col_def.comment = new_comment.map(std::string::ToString::to_string);
55
56            // Build base ALTER TABLE statement using sea-query for type/nullable/default
57            let sea_col = build_sea_column_def_with_table(backend, table, &modified_col_def);
58
59            // Build the ALTER TABLE ... MODIFY COLUMN statement
60            let stmt = sea_query::Table::alter()
61                .table(Alias::new(table))
62                .modify_column(sea_col)
63                .to_owned();
64
65            // Get the base SQL from sea-query
66            let base_sql = super::helpers::build_schema_statement(&stmt, backend);
67
68            // Add COMMENT clause if needed (sea-query doesn't support COMMENT)
69            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            // SQLite doesn't support column comments
80            // We could store the comment in a separate table or just ignore it
81            // For now, we'll skip this operation for SQLite since it doesn't affect the schema
82            // Just update the internal schema representation (handled by apply.rs)
83        }
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    /// Test comment with quotes escaping
159    #[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        // Postgres and MySQL should escape quotes, SQLite returns empty
190        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    /// Test table not found error
212    #[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        // Postgres and SQLite don't need schema lookup, so skip this test for them
218        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    /// Test column not found error
229    #[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        // Postgres and SQLite don't need schema lookup, so skip this test for them
235        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    /// Test with long comment
257    #[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    /// Test preserves column properties when modifying comment
295    #[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        // MySQL should preserve the default value in the MODIFY COLUMN statement
328        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    /// Test changing comment from one value to another
347    #[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    /// Test dropping existing comment
389    #[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, // Drop comment
408            &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    /// Test with different column types
433    #[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    /// Mutant target: `modify_column_comment.rs` line 54 — the MySQL
516    /// branch `comment: new_comment.map(...)` plus the surrounding emit
517    /// path that hand-appends `COMMENT '...'`. Pins EXACT `'hello'`
518    /// literal so any mutation blanking the comment is caught.
519    #[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    /// Test with NOT NULL column
609    #[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}