Skip to main content

vespertide_query/sql/
modify_column_default.rs

1use vespertide_core::TableDef;
2
3use super::helpers::{
4    build_mysql_modify_column_with, build_pg_alter_column_sql, build_sqlite_modify_column_with,
5    find_column_in_schema, normalize_enum_default, quote_ident,
6};
7use super::types::{BuiltQuery, DatabaseBackend, RawSql};
8use crate::error::QueryError;
9
10/// Build SQL for changing column default value.
11///
12/// When `backfill` is `Some(value)`, an `UPDATE` statement is appended after
13/// the schema-level change so every existing row is rewritten to the given
14/// value (F15 backfill option β). The update uses identifier quoting
15/// appropriate for the backend and treats `value` as a raw SQL expression
16/// (already-quoted literals for strings, bare expressions like `NOW()` for
17/// functions). When `backfill` is `None` the action behaves exactly as in
18/// v0.2.0 — only the schema is touched, existing rows keep their values.
19pub fn build_modify_column_default(
20    backend: DatabaseBackend,
21    table: &str,
22    column: &str,
23    new_default: Option<&str>,
24    backfill: Option<&str>,
25    current_schema: &[TableDef],
26    pending_constraints: &[vespertide_core::TableConstraint],
27) -> Result<Vec<BuiltQuery>, QueryError> {
28    let mut queries = Vec::new();
29
30    match backend {
31        DatabaseBackend::Postgres => {
32            let alter_sql = if let Some(default_value) = new_default {
33                // Look up column type to properly quote enum defaults
34                let column_type =
35                    find_column_in_schema(current_schema, table, column).map(|c| &c.r#type);
36
37                let normalized_default = if let Some(col_type) = column_type {
38                    normalize_enum_default(col_type, default_value)
39                } else {
40                    default_value.to_string()
41                };
42
43                build_pg_alter_column_sql(
44                    table,
45                    column,
46                    &format!("SET DEFAULT {normalized_default}"),
47                )
48            } else {
49                build_pg_alter_column_sql(table, column, "DROP DEFAULT")
50            };
51            queries.push(BuiltQuery::Raw(RawSql::uniform(alter_sql)));
52        }
53        DatabaseBackend::MySql => {
54            // MySQL requires the full column definition in ALTER COLUMN.
55            queries.push(build_mysql_modify_column_with(
56                table,
57                column,
58                current_schema,
59                "MySQL requires current schema information to modify column defaults",
60                |c| c.default = new_default.map(std::convert::Into::into),
61            )?);
62        }
63        DatabaseBackend::Sqlite => {
64            // SQLite doesn't support ALTER COLUMN for default changes;
65            // use the canonical temp-table rebuild with the modified column.
66            queries.extend(build_sqlite_modify_column_with(
67                table,
68                column,
69                current_schema,
70                pending_constraints,
71                "SQLite requires current schema information to modify column defaults",
72                |c| c.default = new_default.map(std::convert::Into::into),
73            )?);
74        }
75    }
76
77    // F15 — backfill existing rows when the user explicitly opted in via
78    // the revision prompt. The schema-level change above only affects new
79    // rows; this UPDATE is what brings existing rows in line with the new
80    // default. Emitted *after* the ALTER so the new default is the one
81    // recorded in the catalog before we touch any row.
82    if let Some(value) = backfill {
83        let quoted_table = quote_ident(table, backend);
84        let quoted_column = quote_ident(column, backend);
85        let update_sql = format!("UPDATE {quoted_table} SET {quoted_column} = {value}");
86        queries.push(BuiltQuery::Raw(RawSql::uniform(update_sql)));
87    }
88
89    Ok(queries)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::test_support::{backend_tag, col_n as col, joined_sql, table_def};
96    use insta::{assert_snapshot, with_settings};
97    use rstest::rstest;
98    use vespertide_core::{ColumnType, SimpleColumnType, TableConstraint};
99
100    #[rstest]
101    #[case::postgres_set_default(DatabaseBackend::Postgres, Some("'unknown'"))]
102    #[case::postgres_drop_default(DatabaseBackend::Postgres, None)]
103    #[case::mysql_set_default(DatabaseBackend::MySql, Some("'unknown'"))]
104    #[case::mysql_drop_default(DatabaseBackend::MySql, None)]
105    #[case::sqlite_set_default(DatabaseBackend::Sqlite, Some("'unknown'"))]
106    #[case::sqlite_drop_default(DatabaseBackend::Sqlite, None)]
107    fn test_build_modify_column_default(
108        #[case] backend: DatabaseBackend,
109        #[case] new_default: Option<&str>,
110    ) {
111        let schema = vec![table_def(
112            "users",
113            vec![
114                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
115                col("email", ColumnType::Simple(SimpleColumnType::Text), true),
116            ],
117            vec![],
118        )];
119
120        let result =
121            build_modify_column_default(backend, "users", "email", new_default, None, &schema, &[]);
122        assert!(result.is_ok());
123        let queries = result.unwrap();
124        let sql = joined_sql(backend, &queries);
125
126        let suffix = format!(
127            "{}_{}_users",
128            backend_tag(backend),
129            if new_default.is_some() {
130                "set_default"
131            } else {
132                "drop_default"
133            }
134        );
135
136        with_settings!({ snapshot_suffix => suffix }, {
137            assert_snapshot!(sql);
138        });
139    }
140
141    /// Test table not found error
142    #[rstest]
143    #[case::postgres_table_not_found(DatabaseBackend::Postgres)]
144    #[case::mysql_table_not_found(DatabaseBackend::MySql)]
145    #[case::sqlite_table_not_found(DatabaseBackend::Sqlite)]
146    fn test_table_not_found(#[case] backend: DatabaseBackend) {
147        // Postgres doesn't need schema lookup for default changes
148        if backend == DatabaseBackend::Postgres {
149            return;
150        }
151
152        let result = build_modify_column_default(
153            backend,
154            "users",
155            "email",
156            Some("'default'"),
157            None,
158            &[],
159            &[],
160        );
161        assert!(result.is_err());
162        let err_msg = result.unwrap_err().to_string();
163        assert!(err_msg.contains("Table 'users' not found"));
164    }
165
166    /// Test column not found error
167    #[rstest]
168    #[case::postgres_column_not_found(DatabaseBackend::Postgres)]
169    #[case::mysql_column_not_found(DatabaseBackend::MySql)]
170    #[case::sqlite_column_not_found(DatabaseBackend::Sqlite)]
171    fn test_column_not_found(#[case] backend: DatabaseBackend) {
172        // Postgres doesn't need schema lookup for default changes
173        // SQLite doesn't validate column existence in modify_column_default
174        if backend == DatabaseBackend::Postgres || backend == DatabaseBackend::Sqlite {
175            return;
176        }
177
178        let schema = vec![table_def(
179            "users",
180            vec![col(
181                "id",
182                ColumnType::Simple(SimpleColumnType::Integer),
183                false,
184            )],
185            vec![],
186        )];
187
188        let result = build_modify_column_default(
189            backend,
190            "users",
191            "email",
192            Some("'default'"),
193            None,
194            &schema,
195            &[],
196        );
197        assert!(result.is_err());
198        let err_msg = result.unwrap_err().to_string();
199        assert!(err_msg.contains("Column 'email' not found"));
200    }
201
202    /// Test Postgres default change when column is not in schema
203    /// This covers the fallback path where `column_type` is None
204    #[test]
205    fn test_postgres_column_not_in_schema_uses_default_as_is() {
206        let schema = vec![table_def(
207            "users",
208            vec![col(
209                "id",
210                ColumnType::Simple(SimpleColumnType::Integer),
211                false,
212            )],
213            // Note: "status" column is NOT in the schema
214            vec![],
215        )];
216
217        // Postgres doesn't error when column isn't found - it just uses the default as-is
218        let result = build_modify_column_default(
219            DatabaseBackend::Postgres,
220            "users",
221            "status", // column not in schema
222            Some("'active'"),
223            None,
224            &schema,
225            &[],
226        );
227        assert!(result.is_ok());
228        let queries = result.unwrap();
229        let sql = joined_sql(DatabaseBackend::Postgres, &queries);
230
231        // Should still generate valid SQL, using the default value as-is
232        assert!(sql.contains("ALTER TABLE \"users\" ALTER COLUMN \"status\" SET DEFAULT 'active'"));
233    }
234
235    /// Test with index - should recreate index after table rebuild (`SQLite`)
236    #[rstest]
237    #[case::postgres_with_index(DatabaseBackend::Postgres)]
238    #[case::mysql_with_index(DatabaseBackend::MySql)]
239    #[case::sqlite_with_index(DatabaseBackend::Sqlite)]
240    fn test_modify_default_with_index(#[case] backend: DatabaseBackend) {
241        let schema = vec![table_def(
242            "users",
243            vec![
244                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
245                col("email", ColumnType::Simple(SimpleColumnType::Text), true),
246            ],
247            vec![TableConstraint::Index {
248                name: Some("idx_users_email".into()),
249                columns: vec!["email".into()],
250            }],
251        )];
252
253        let result = build_modify_column_default(
254            backend,
255            "users",
256            "email",
257            Some("'default@example.com'"),
258            None,
259            &schema,
260            &[],
261        );
262        assert!(result.is_ok());
263        let queries = result.unwrap();
264        let sql = joined_sql(backend, &queries);
265
266        // SQLite should recreate the index after table rebuild
267        if backend == DatabaseBackend::Sqlite {
268            assert!(sql.contains("CREATE INDEX"));
269            assert!(sql.contains("idx_users_email"));
270        }
271
272        let suffix = format!("{}_with_index", backend_tag(backend));
273
274        with_settings!({ snapshot_suffix => suffix }, {
275            assert_snapshot!(sql);
276        });
277    }
278
279    /// Test changing default value from one to another
280    #[rstest]
281    #[case::postgres_change_default(DatabaseBackend::Postgres)]
282    #[case::mysql_change_default(DatabaseBackend::MySql)]
283    #[case::sqlite_change_default(DatabaseBackend::Sqlite)]
284    fn test_change_default_value(#[case] backend: DatabaseBackend) {
285        let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
286        email_col.default = Some("'old@example.com'".into());
287
288        let schema = vec![table_def(
289            "users",
290            vec![
291                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
292                email_col,
293            ],
294            vec![],
295        )];
296
297        let result = build_modify_column_default(
298            backend,
299            "users",
300            "email",
301            Some("'new@example.com'"),
302            None,
303            &schema,
304            &[],
305        );
306        assert!(result.is_ok());
307        let queries = result.unwrap();
308        let sql = joined_sql(backend, &queries);
309
310        let suffix = format!("{}_change_default", backend_tag(backend));
311
312        with_settings!({ snapshot_suffix => suffix }, {
313            assert_snapshot!(sql);
314        });
315    }
316
317    /// Test with integer default value
318    #[rstest]
319    #[case::postgres_integer_default(DatabaseBackend::Postgres)]
320    #[case::mysql_integer_default(DatabaseBackend::MySql)]
321    #[case::sqlite_integer_default(DatabaseBackend::Sqlite)]
322    fn test_integer_default(#[case] backend: DatabaseBackend) {
323        let schema = vec![table_def(
324            "products",
325            vec![
326                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
327                col(
328                    "quantity",
329                    ColumnType::Simple(SimpleColumnType::Integer),
330                    false,
331                ),
332            ],
333            vec![],
334        )];
335
336        let result = build_modify_column_default(
337            backend,
338            "products",
339            "quantity",
340            Some("0"),
341            None,
342            &schema,
343            &[],
344        );
345        assert!(result.is_ok());
346        let queries = result.unwrap();
347        let sql = joined_sql(backend, &queries);
348
349        let suffix = format!("{}_integer_default", backend_tag(backend));
350
351        with_settings!({ snapshot_suffix => suffix }, {
352            assert_snapshot!(sql);
353        });
354    }
355
356    /// Test with boolean default value
357    #[rstest]
358    #[case::postgres_boolean_default(DatabaseBackend::Postgres)]
359    #[case::mysql_boolean_default(DatabaseBackend::MySql)]
360    #[case::sqlite_boolean_default(DatabaseBackend::Sqlite)]
361    fn test_boolean_default(#[case] backend: DatabaseBackend) {
362        let schema = vec![table_def(
363            "users",
364            vec![
365                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
366                col(
367                    "is_active",
368                    ColumnType::Simple(SimpleColumnType::Boolean),
369                    false,
370                ),
371            ],
372            vec![],
373        )];
374
375        let result = build_modify_column_default(
376            backend,
377            "users",
378            "is_active",
379            Some("true"),
380            None,
381            &schema,
382            &[],
383        );
384        assert!(result.is_ok());
385        let queries = result.unwrap();
386        let sql = joined_sql(backend, &queries);
387
388        let suffix = format!("{}_boolean_default", backend_tag(backend));
389
390        with_settings!({ snapshot_suffix => suffix }, {
391            assert_snapshot!(sql);
392        });
393    }
394
395    /// Test with function default (e.g., `NOW()`, `CURRENT_TIMESTAMP`)
396    #[rstest]
397    #[case::postgres_function_default(DatabaseBackend::Postgres)]
398    #[case::mysql_function_default(DatabaseBackend::MySql)]
399    #[case::sqlite_function_default(DatabaseBackend::Sqlite)]
400    fn test_function_default(#[case] backend: DatabaseBackend) {
401        let schema = vec![table_def(
402            "events",
403            vec![
404                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
405                col(
406                    "created_at",
407                    ColumnType::Simple(SimpleColumnType::Timestamp),
408                    false,
409                ),
410            ],
411            vec![],
412        )];
413
414        let default_value = match backend {
415            DatabaseBackend::Postgres => "NOW()",
416            DatabaseBackend::MySql | DatabaseBackend::Sqlite => "CURRENT_TIMESTAMP",
417        };
418
419        let result = build_modify_column_default(
420            backend,
421            "events",
422            "created_at",
423            Some(default_value),
424            None,
425            &schema,
426            &[],
427        );
428        assert!(result.is_ok());
429        let queries = result.unwrap();
430        let sql = joined_sql(backend, &queries);
431
432        let suffix = format!("{}_function_default", backend_tag(backend));
433
434        with_settings!({ snapshot_suffix => suffix }, {
435            assert_snapshot!(sql);
436        });
437    }
438
439    /// Test dropping default from column that had one
440    #[rstest]
441    #[case::postgres_drop_existing_default(DatabaseBackend::Postgres)]
442    #[case::mysql_drop_existing_default(DatabaseBackend::MySql)]
443    #[case::sqlite_drop_existing_default(DatabaseBackend::Sqlite)]
444    fn test_drop_existing_default(#[case] backend: DatabaseBackend) {
445        let mut status_col = col("status", ColumnType::Simple(SimpleColumnType::Text), false);
446        status_col.default = Some("'pending'".into());
447
448        let schema = vec![table_def(
449            "orders",
450            vec![
451                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
452                status_col,
453            ],
454            vec![],
455        )];
456
457        let result = build_modify_column_default(
458            backend,
459            "orders",
460            "status",
461            None, // Drop default
462            None, // No backfill
463            &schema,
464            &[],
465        );
466        assert!(result.is_ok());
467        let queries = result.unwrap();
468        let sql = joined_sql(backend, &queries);
469
470        let suffix = format!("{}_drop_existing_default", backend_tag(backend));
471
472        with_settings!({ snapshot_suffix => suffix }, {
473            assert_snapshot!(sql);
474        });
475    }
476
477    /// Test that `backfill = Some(value)` emits the trailing `UPDATE` that
478    /// rewrites every existing row. Covers the post-ALTER backfill block
479    /// (the `if let Some(value) = backfill { ... }` body) for all backends.
480    #[rstest]
481    #[case::postgres(DatabaseBackend::Postgres)]
482    #[case::mysql(DatabaseBackend::MySql)]
483    #[case::sqlite(DatabaseBackend::Sqlite)]
484    fn build_modify_column_default_with_backfill_emits_update_statement(
485        #[case] backend: DatabaseBackend,
486    ) {
487        let schema = vec![table_def(
488            "users",
489            vec![
490                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
491                col("status", ColumnType::Simple(SimpleColumnType::Text), false),
492            ],
493            vec![],
494        )];
495
496        let queries = build_modify_column_default(
497            backend,
498            "users",
499            "status",
500            Some("'active'"),
501            Some("'active'"),
502            &schema,
503            &[],
504        )
505        .expect("backfill path should succeed");
506        let sql = joined_sql(backend, &queries);
507
508        // The trailing UPDATE was emitted exactly once.
509        let update_count = sql.matches("UPDATE").count();
510        assert!(update_count >= 1, "expected backfill UPDATE in: {sql}");
511        assert!(sql.contains("SET"));
512        assert!(sql.contains("status"));
513        assert!(sql.contains("'active'"));
514    }
515
516    /// `backfill` is a raw SQL expression slot, so it is interpolated
517    /// verbatim. This locks that contract against the `fill_with` defect
518    /// (first-`::` split + `to_lowercase`) ever being copied onto this path.
519    #[rstest]
520    #[case::postgres(DatabaseBackend::Postgres)]
521    #[case::mysql(DatabaseBackend::MySql)]
522    #[case::sqlite(DatabaseBackend::Sqlite)]
523    fn build_modify_column_default_backfill_expression_is_verbatim(
524        #[case] backend: DatabaseBackend,
525    ) {
526        const BACKFILL: &str = "(CASE WHEN plan_key::text = 'API' THEN 'MONTHLY_QUOTA' ELSE 'SEAT' END)::billing_metric";
527
528        let schema = vec![table_def(
529            "users",
530            vec![
531                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
532                col(
533                    "plan_key",
534                    ColumnType::Simple(SimpleColumnType::Text),
535                    false,
536                ),
537                col("metric", ColumnType::Simple(SimpleColumnType::Text), false),
538            ],
539            vec![],
540        )];
541
542        let queries = build_modify_column_default(
543            backend,
544            "users",
545            "metric",
546            None,
547            Some(BACKFILL),
548            &schema,
549            &[],
550        )
551        .expect("backfill path should succeed");
552        let sql = joined_sql(backend, &queries);
553
554        assert!(
555            sql.contains(BACKFILL),
556            "backfill must survive byte-for-byte, got: {sql}"
557        );
558    }
559}