Skip to main content

vespertide_query/sql/
modify_column_nullable.rs

1use vespertide_core::TableDef;
2
3use super::fill_with::convert_fill_with_for_backend;
4use super::helpers::{
5    build_mysql_modify_column_with, build_pg_alter_column_sql, build_sqlite_modify_column_with,
6    normalize_fill_with, quote_ident,
7};
8use super::types::{BuiltQuery, DatabaseBackend, RawSql};
9use crate::error::QueryError;
10
11/// Build SQL for changing column nullability.
12/// For nullable -> non-nullable transitions, `fill_with` should be provided to update NULL values.
13#[expect(
14    clippy::too_many_arguments,
15    reason = "nullability builder needs action fields, fill strategy, backend, and SQLite rebuild context; NullabilityContext is deferred"
16)]
17pub fn build_modify_column_nullable(
18    backend: DatabaseBackend,
19    table: &str,
20    column: &str,
21    nullable: bool,
22    fill_with: Option<&str>,
23    delete_null_rows: bool,
24    current_schema: &[TableDef],
25    pending_constraints: &[vespertide_core::TableConstraint],
26) -> Result<Vec<BuiltQuery>, QueryError> {
27    let mut queries = Vec::new();
28
29    // If delete_null_rows is set, delete rows with NULL values instead of updating
30    if !nullable && delete_null_rows {
31        let quoted_table = quote_ident(table, backend);
32        let quoted_column = quote_ident(column, backend);
33        let delete_sql = format!("DELETE FROM {quoted_table} WHERE {quoted_column} IS NULL");
34        queries.push(BuiltQuery::Raw(RawSql::uniform(delete_sql)));
35    }
36    // If changing to NOT NULL, first update existing NULL values if fill_with is provided
37    else if !nullable && let Some(fill_value) = normalize_fill_with(fill_with) {
38        let fill_value = convert_fill_with_for_backend(fill_value, backend);
39        let quoted_table = quote_ident(table, backend);
40        let quoted_column = quote_ident(column, backend);
41        let update_sql = format!(
42            "UPDATE {quoted_table} SET {quoted_column} = {fill_value} WHERE {quoted_column} IS NULL"
43        );
44        queries.push(BuiltQuery::Raw(RawSql::uniform(update_sql)));
45    }
46
47    // Generate ALTER TABLE statement based on backend
48    match backend {
49        DatabaseBackend::Postgres => {
50            let alter_sql = if nullable {
51                build_pg_alter_column_sql(table, column, "DROP NOT NULL")
52            } else {
53                build_pg_alter_column_sql(table, column, "SET NOT NULL")
54            };
55            queries.push(BuiltQuery::Raw(RawSql::uniform(alter_sql)));
56        }
57        DatabaseBackend::MySql => {
58            // MySQL requires the full column definition in MODIFY COLUMN.
59            queries.push(build_mysql_modify_column_with(
60                table,
61                column,
62                current_schema,
63                "MySQL requires current schema information to modify column nullability",
64                |c| c.nullable = nullable,
65            )?);
66        }
67        DatabaseBackend::Sqlite => {
68            // SQLite doesn't support ALTER COLUMN for nullability changes;
69            // use the canonical temp-table rebuild with the modified column.
70            queries.extend(build_sqlite_modify_column_with(
71                table,
72                column,
73                current_schema,
74                pending_constraints,
75                "SQLite requires current schema information to modify column nullability",
76                |c| c.nullable = nullable,
77            )?);
78        }
79    }
80
81    Ok(queries)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::test_support::{backend_tag, col_n as col, joined_sql, table_def};
88    use insta::{assert_snapshot, with_settings};
89    use rstest::rstest;
90    use vespertide_core::{ColumnType, SimpleColumnType, TableConstraint};
91
92    #[rstest]
93    #[case::postgres_set_not_null(DatabaseBackend::Postgres, false, None)]
94    #[case::postgres_drop_not_null(DatabaseBackend::Postgres, true, None)]
95    #[case::postgres_set_not_null_with_fill(DatabaseBackend::Postgres, false, Some("'unknown'"))]
96    #[case::mysql_set_not_null(DatabaseBackend::MySql, false, None)]
97    #[case::mysql_drop_not_null(DatabaseBackend::MySql, true, None)]
98    #[case::mysql_set_not_null_with_fill(DatabaseBackend::MySql, false, Some("'unknown'"))]
99    #[case::sqlite_set_not_null(DatabaseBackend::Sqlite, false, None)]
100    #[case::sqlite_drop_not_null(DatabaseBackend::Sqlite, true, None)]
101    #[case::sqlite_set_not_null_with_fill(DatabaseBackend::Sqlite, false, Some("'unknown'"))]
102    fn test_build_modify_column_nullable(
103        #[case] backend: DatabaseBackend,
104        #[case] nullable: bool,
105        #[case] fill_with: Option<&str>,
106    ) {
107        let schema = vec![table_def(
108            "users",
109            vec![
110                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
111                col(
112                    "email",
113                    ColumnType::Simple(SimpleColumnType::Text),
114                    !nullable,
115                ),
116            ],
117            vec![],
118        )];
119
120        let result = build_modify_column_nullable(
121            backend,
122            "users",
123            "email",
124            nullable,
125            fill_with,
126            false,
127            &schema,
128            &[],
129        );
130        assert!(result.is_ok());
131        let queries = result.unwrap();
132        let sql = joined_sql(backend, &queries);
133
134        let suffix = format!(
135            "{}_{}_users{}",
136            backend_tag(backend),
137            if nullable { "nullable" } else { "not_null" },
138            if fill_with.is_some() {
139                "_with_fill"
140            } else {
141                ""
142            }
143        );
144
145        with_settings!({ snapshot_suffix => suffix }, {
146            assert_snapshot!(sql);
147        });
148    }
149
150    /// Test table not found error
151    #[rstest]
152    #[case::postgres_table_not_found(DatabaseBackend::Postgres)]
153    #[case::mysql_table_not_found(DatabaseBackend::MySql)]
154    #[case::sqlite_table_not_found(DatabaseBackend::Sqlite)]
155    fn test_table_not_found(#[case] backend: DatabaseBackend) {
156        // Postgres doesn't need schema lookup for nullability changes
157        if backend == DatabaseBackend::Postgres {
158            return;
159        }
160
161        let result =
162            build_modify_column_nullable(backend, "users", "email", false, None, false, &[], &[]);
163        assert!(result.is_err());
164        let err_msg = result.unwrap_err().to_string();
165        assert!(err_msg.contains("Table 'users' not found"));
166    }
167
168    /// Test column not found error
169    #[rstest]
170    #[case::postgres_column_not_found(DatabaseBackend::Postgres)]
171    #[case::mysql_column_not_found(DatabaseBackend::MySql)]
172    #[case::sqlite_column_not_found(DatabaseBackend::Sqlite)]
173    fn test_column_not_found(#[case] backend: DatabaseBackend) {
174        // Postgres doesn't need schema lookup for nullability changes
175        // SQLite doesn't validate column existence in modify_column_nullable
176        if backend == DatabaseBackend::Postgres || backend == DatabaseBackend::Sqlite {
177            return;
178        }
179
180        let schema = vec![table_def(
181            "users",
182            vec![col(
183                "id",
184                ColumnType::Simple(SimpleColumnType::Integer),
185                false,
186            )],
187            vec![],
188        )];
189
190        let result = build_modify_column_nullable(
191            backend,
192            "users",
193            "email",
194            false,
195            None,
196            false,
197            &schema,
198            &[],
199        );
200        assert!(result.is_err());
201        let err_msg = result.unwrap_err().to_string();
202        assert!(err_msg.contains("Column 'email' not found"));
203    }
204
205    /// Test with index - should recreate index after table rebuild (`SQLite`)
206    #[rstest]
207    #[case::postgres_with_index(DatabaseBackend::Postgres)]
208    #[case::mysql_with_index(DatabaseBackend::MySql)]
209    #[case::sqlite_with_index(DatabaseBackend::Sqlite)]
210    fn test_modify_nullable_with_index(#[case] backend: DatabaseBackend) {
211        let schema = vec![table_def(
212            "users",
213            vec![
214                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
215                col("email", ColumnType::Simple(SimpleColumnType::Text), true),
216            ],
217            vec![TableConstraint::Index {
218                name: Some("idx_email".into()),
219                columns: vec!["email".into()],
220            }],
221        )];
222
223        let result = build_modify_column_nullable(
224            backend,
225            "users",
226            "email",
227            false,
228            None,
229            false,
230            &schema,
231            &[],
232        );
233        assert!(result.is_ok());
234        let queries = result.unwrap();
235        let sql = joined_sql(backend, &queries);
236
237        // SQLite should recreate the index after table rebuild
238        if backend == DatabaseBackend::Sqlite {
239            assert!(sql.contains("CREATE INDEX"));
240            assert!(sql.contains("idx_email"));
241        }
242
243        let suffix = format!("{}_with_index", backend_tag(backend));
244
245        with_settings!({ snapshot_suffix => suffix }, {
246            assert_snapshot!(sql);
247        });
248    }
249
250    /// Test `fill_with` containing `NOW()`.
251    ///
252    /// `fill_with` is a raw SQL expression slot, so PostgreSQL — the dialect
253    /// it is authored in — now emits it verbatim. MySQL and SQLite still get
254    /// `CURRENT_TIMESTAMP` because `NOW()` is not a SQLite function; that
255    /// rewrite is safe only because the spelling is matched against the whole
256    /// value, never a fragment of a larger expression.
257    #[rstest]
258    #[case::postgres_fill_now(DatabaseBackend::Postgres)]
259    #[case::mysql_fill_now(DatabaseBackend::MySql)]
260    #[case::sqlite_fill_now(DatabaseBackend::Sqlite)]
261    fn test_fill_with_now_converted_to_current_timestamp(#[case] backend: DatabaseBackend) {
262        let schema = vec![table_def(
263            "orders",
264            vec![
265                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
266                col(
267                    "paid_at",
268                    ColumnType::Simple(SimpleColumnType::Timestamptz),
269                    true,
270                ),
271            ],
272            vec![],
273        )];
274
275        let result = build_modify_column_nullable(
276            backend,
277            "orders",
278            "paid_at",
279            false,
280            Some("NOW()"),
281            false,
282            &schema,
283            &[],
284        );
285        assert!(result.is_ok());
286        let queries = result.unwrap();
287        let sql = joined_sql(backend, &queries);
288
289        if backend == DatabaseBackend::Postgres {
290            assert!(
291                sql.contains("NOW()"),
292                "PostgreSQL must emit fill_with verbatim, got: {sql}"
293            );
294        } else {
295            assert!(
296                !sql.contains("NOW()"),
297                "SQL should not contain NOW(), got: {sql}"
298            );
299            assert!(
300                sql.contains("CURRENT_TIMESTAMP"),
301                "SQL should contain CURRENT_TIMESTAMP, got: {sql}"
302            );
303        }
304
305        let suffix = format!("{}_fill_now", backend_tag(backend));
306
307        with_settings!({ snapshot_suffix => suffix }, {
308            assert_snapshot!(sql);
309        });
310    }
311
312    /// Test with default value - should preserve default in MODIFY COLUMN (`MySQL`)
313    #[rstest]
314    #[case::postgres_with_default(DatabaseBackend::Postgres)]
315    #[case::mysql_with_default(DatabaseBackend::MySql)]
316    #[case::sqlite_with_default(DatabaseBackend::Sqlite)]
317    fn test_with_default_value(#[case] backend: DatabaseBackend) {
318        let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text), true);
319        email_col.default = Some("'default@example.com'".into());
320
321        let schema = vec![table_def(
322            "users",
323            vec![
324                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
325                email_col,
326            ],
327            vec![],
328        )];
329
330        let result = build_modify_column_nullable(
331            backend,
332            "users",
333            "email",
334            false,
335            None,
336            false,
337            &schema,
338            &[],
339        );
340        assert!(result.is_ok());
341        let queries = result.unwrap();
342        let sql = joined_sql(backend, &queries);
343
344        // MySQL and SQLite should include DEFAULT clause
345        if backend == DatabaseBackend::MySql || backend == DatabaseBackend::Sqlite {
346            assert!(sql.contains("DEFAULT"));
347        }
348
349        let suffix = format!("{}_with_default", backend_tag(backend));
350
351        with_settings!({ snapshot_suffix => suffix }, {
352            assert_snapshot!(sql);
353        });
354    }
355
356    /// Test `delete_null_rows` generates DELETE instead of UPDATE
357    #[rstest]
358    #[case::postgres_delete_null_rows(DatabaseBackend::Postgres)]
359    #[case::mysql_delete_null_rows(DatabaseBackend::MySql)]
360    #[case::sqlite_delete_null_rows(DatabaseBackend::Sqlite)]
361    fn test_delete_null_rows(#[case] backend: DatabaseBackend) {
362        let schema = vec![table_def(
363            "orders",
364            vec![
365                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
366                col(
367                    "user_id",
368                    ColumnType::Simple(SimpleColumnType::Integer),
369                    true,
370                ),
371            ],
372            vec![],
373        )];
374
375        let result = build_modify_column_nullable(
376            backend,
377            "orders",
378            "user_id",
379            false,
380            None,
381            true,
382            &schema,
383            &[],
384        );
385        assert!(result.is_ok());
386        let queries = result.unwrap();
387        let sql = joined_sql(backend, &queries);
388
389        assert!(
390            sql.contains("DELETE FROM"),
391            "Expected DELETE FROM in SQL, got: {sql}"
392        );
393        assert!(
394            sql.contains("IS NULL"),
395            "Expected IS NULL in SQL, got: {sql}"
396        );
397        assert!(
398            !sql.contains("UPDATE"),
399            "Should NOT contain UPDATE, got: {sql}"
400        );
401
402        let suffix = format!("{}_delete_null_rows", backend_tag(backend));
403
404        with_settings!({ snapshot_suffix => suffix }, {
405            assert_snapshot!(sql);
406        });
407    }
408
409    /// Test `delete_null_rows=true` with nullable=true does nothing special
410    #[rstest]
411    #[case::postgres_delete_null_rows_nullable(DatabaseBackend::Postgres)]
412    fn test_delete_null_rows_with_nullable_true(#[case] backend: DatabaseBackend) {
413        let schema = vec![table_def(
414            "orders",
415            vec![
416                col("id", ColumnType::Simple(SimpleColumnType::Integer), false),
417                col(
418                    "user_id",
419                    ColumnType::Simple(SimpleColumnType::Integer),
420                    false,
421                ),
422            ],
423            vec![],
424        )];
425
426        let result = build_modify_column_nullable(
427            backend,
428            "orders",
429            "user_id",
430            true,
431            None,
432            true,
433            &schema,
434            &[],
435        );
436        assert!(result.is_ok());
437        let queries = result.unwrap();
438        let sql = joined_sql(backend, &queries);
439
440        assert!(
441            !sql.contains("DELETE FROM"),
442            "Should NOT contain DELETE when nullable=true, got: {sql}"
443        );
444    }
445}