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#[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 !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 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 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 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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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}