fix: quote temporal type default values in SQL generation
Time-related column types (DATE, DATETIME, TIMESTAMP, TIME, etc.) need their literal default values wrapped in single quotes. Previously, the normalize_default function returned values verbatim, so a DATETIME default like "2024-01-01 00:00:00" would generate invalid SQL: DEFAULT 2024-01-01 00:00:00 instead of: DEFAULT '2024-01-01 00:00:00' SQL expressions (CURRENT_TIMESTAMP, NOW(), etc.) are correctly left unquoted. Non-temporal types (INT, VARCHAR, etc.) are unaffected.
This commit is contained in:
parent
e02b6f0d7a
commit
5581205c4d
|
|
@ -395,7 +395,7 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
|
|||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
parts.push(format!("DEFAULT {default_value}"));
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
}
|
||||
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
|
||||
if on_update && dialect == StructureDialect::Mysql {
|
||||
|
|
@ -925,8 +925,14 @@ fn build_postgres_existing_column_sql(table: &str, column: &EditableStructureCol
|
|||
}
|
||||
if normalize_default(Some(&column.default_value)) != original_default(column) {
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
let action =
|
||||
if default_value.is_empty() { "DROP DEFAULT".to_string() } else { format!("SET DEFAULT {default_value}") };
|
||||
let action = if default_value.is_empty() {
|
||||
"DROP DEFAULT".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"SET DEFAULT {}",
|
||||
format_default_for_sql(StructureDialect::Postgres, &column.data_type, &default_value)
|
||||
)
|
||||
};
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {action};",
|
||||
quote_ident(StructureDialect::Postgres, current_name)
|
||||
|
|
@ -975,7 +981,7 @@ fn build_oracle_like_existing_column_sql(
|
|||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
parts.push(format!("DEFAULT {default_value}"));
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
} else if default_changed {
|
||||
// User cleared the default — explicitly drop it.
|
||||
parts.push("DEFAULT NULL".to_string());
|
||||
|
|
@ -1062,7 +1068,8 @@ fn build_sqlserver_existing_column_sql(
|
|||
col_name = current_name.trim_matches(|c: char| c == '[' || c == ']')
|
||||
);
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ADD CONSTRAINT [{constraint_name}] DEFAULT {default_value} FOR {};",
|
||||
"ALTER TABLE {table} ADD CONSTRAINT [{constraint_name}] DEFAULT {} FOR {};",
|
||||
format_default_for_sql(StructureDialect::SqlServer, &column.data_type, &default_value),
|
||||
quote_ident(dialect, ¤t_name)
|
||||
));
|
||||
}
|
||||
|
|
@ -1112,8 +1119,11 @@ fn build_h2_existing_column_sql(table: &str, column: &EditableStructureColumn) -
|
|||
}
|
||||
if normalize_default(Some(&column.default_value)) != original_default(column) {
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
let action =
|
||||
if default_value.is_empty() { "DROP DEFAULT".to_string() } else { format!("SET DEFAULT {default_value}") };
|
||||
let action = if default_value.is_empty() {
|
||||
"DROP DEFAULT".to_string()
|
||||
} else {
|
||||
format!("SET DEFAULT {}", format_default_for_sql(StructureDialect::H2, &column.data_type, &default_value))
|
||||
};
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {} {action};",
|
||||
quote_ident(StructureDialect::H2, ¤t_name)
|
||||
|
|
@ -1154,8 +1164,9 @@ fn build_clickhouse_existing_column_sql(
|
|||
{
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
let default_sql = format_default_for_sql(StructureDialect::ClickHouse, &column.data_type, &default_value);
|
||||
statements.push(format!(
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} {next_type} DEFAULT {default_value}{position_clause};",
|
||||
"ALTER TABLE {table} MODIFY COLUMN {} {next_type} DEFAULT {default_sql}{position_clause};",
|
||||
quote_ident(StructureDialect::ClickHouse, ¤t_name)
|
||||
));
|
||||
} else if !original_default(column).is_empty() {
|
||||
|
|
@ -1361,7 +1372,7 @@ fn column_definition(dialect: StructureDialect, column: &EditableStructureColumn
|
|||
}
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
parts.push(format!("DEFAULT {default_value}"));
|
||||
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
|
||||
}
|
||||
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
|
||||
if on_update && dialect == StructureDialect::Mysql {
|
||||
|
|
@ -1617,6 +1628,83 @@ fn clean(value: &str) -> String {
|
|||
value.trim().to_string()
|
||||
}
|
||||
|
||||
fn is_temporal_type_for_default(dialect: StructureDialect, base_type: &str) -> bool {
|
||||
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
|
||||
match dialect {
|
||||
StructureDialect::Mysql => matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time" | "year"),
|
||||
StructureDialect::Postgres => {
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"date"
|
||||
| "time"
|
||||
| "time without time zone"
|
||||
| "time with time zone"
|
||||
| "timestamp"
|
||||
| "timestamp without time zone"
|
||||
| "timestamp with time zone"
|
||||
| "interval"
|
||||
| "timetz"
|
||||
| "timestamptz"
|
||||
) || normalized.starts_with("interval ")
|
||||
}
|
||||
StructureDialect::SqlServer => matches!(
|
||||
normalized.as_str(),
|
||||
"date" | "time" | "datetime" | "datetime2" | "smalldatetime" | "datetimeoffset"
|
||||
),
|
||||
StructureDialect::Oracle => matches!(
|
||||
normalized.as_str(),
|
||||
"date"
|
||||
| "timestamp"
|
||||
| "timestamp with time zone"
|
||||
| "timestamp with local time zone"
|
||||
| "interval year to month"
|
||||
| "interval day to second"
|
||||
),
|
||||
StructureDialect::H2 => {
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"date"
|
||||
| "time"
|
||||
| "time without time zone"
|
||||
| "time with time zone"
|
||||
| "timestamp"
|
||||
| "timestamp without time zone"
|
||||
| "timestamp with time zone"
|
||||
) || normalized.starts_with("interval ")
|
||||
}
|
||||
StructureDialect::ClickHouse => {
|
||||
matches!(normalized.as_str(), "date" | "date32" | "datetime" | "datetime64")
|
||||
}
|
||||
StructureDialect::Sqlite => {
|
||||
matches!(normalized.as_str(), "date" | "datetime" | "timestamp" | "time")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_temporal_expression(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if trimmed.contains('(') || trimmed.contains(')') {
|
||||
return true;
|
||||
}
|
||||
trimmed.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
}
|
||||
|
||||
fn format_default_for_sql(dialect: StructureDialect, data_type: &str, default_value: &str) -> String {
|
||||
if default_value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let base_type = data_type.split('(').next().unwrap_or(data_type).trim();
|
||||
if is_temporal_type_for_default(dialect, base_type) && !is_temporal_expression(default_value) {
|
||||
quote_string(default_value)
|
||||
} else {
|
||||
default_value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_default(value: Option<&String>) -> String {
|
||||
let trimmed = value.map(|value| value.trim()).unwrap_or("");
|
||||
if trimmed.eq_ignore_ascii_case("null") {
|
||||
|
|
@ -2462,4 +2550,176 @@ mod tests {
|
|||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("IDENTITY(100, 5)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_quotes_datetime_literal_default() {
|
||||
let mut col = column("created_at");
|
||||
col.data_type = "datetime".to_string();
|
||||
col.default_value = "2024-01-01 00:00:00".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT '2024-01-01 00:00:00'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_does_not_quote_current_timestamp() {
|
||||
let mut col = column("updated_at");
|
||||
col.data_type = "timestamp".to_string();
|
||||
col.default_value = "CURRENT_TIMESTAMP".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT CURRENT_TIMESTAMP"));
|
||||
assert!(!result.statements[0].contains("DEFAULT 'CURRENT_TIMESTAMP'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_does_not_quote_temporal_function_with_parens() {
|
||||
let mut col = column("created_at");
|
||||
col.data_type = "datetime".to_string();
|
||||
col.default_value = "NOW()".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT NOW()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_date_literal_default_is_quoted() {
|
||||
let mut col = column("birth_date");
|
||||
col.data_type = "date".to_string();
|
||||
col.default_value = "2000-01-01".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "users".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT '2000-01-01'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_time_literal_default_is_quoted() {
|
||||
let mut col = column("start_time");
|
||||
col.data_type = "time".to_string();
|
||||
col.default_value = "09:00:00".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "shifts".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT '09:00:00'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_temporal_types_are_not_quoted() {
|
||||
let mut col = column("score");
|
||||
col.data_type = "int".to_string();
|
||||
col.default_value = "0".to_string();
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "games".to_string(),
|
||||
columns: vec![col],
|
||||
indexes: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements[0].contains("DEFAULT 0"));
|
||||
assert!(!result.statements[0].contains("DEFAULT '0'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_timestamp_literal_is_quoted() {
|
||||
let mut col = column("logged_at");
|
||||
col.data_type = "timestamp".to_string();
|
||||
col.default_value = "2024-06-01 12:00:00".to_string();
|
||||
col.original = Some(ColumnInfo {
|
||||
name: "logged_at".to_string(),
|
||||
data_type: "timestamp".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some(String::new()),
|
||||
});
|
||||
|
||||
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "events".to_string(),
|
||||
column: col,
|
||||
});
|
||||
|
||||
assert!(result.statements.iter().any(|s| s.contains("SET DEFAULT '2024-06-01 12:00:00'")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_single_column_alter_quotes_datetime_literal() {
|
||||
let mut col = column("created_at");
|
||||
col.data_type = "datetime".to_string();
|
||||
col.default_value = "2024-01-01 00:00:00".to_string();
|
||||
col.original = Some(ColumnInfo {
|
||||
name: "created_at".to_string(),
|
||||
data_type: "datetime".to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_primary_key: false,
|
||||
extra: None,
|
||||
comment: Some(String::new()),
|
||||
});
|
||||
|
||||
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
schema: None,
|
||||
table_name: "events".to_string(),
|
||||
column: col,
|
||||
});
|
||||
|
||||
assert!(result.statements.iter().any(|s| s.contains("DEFAULT '2024-01-01 00:00:00'")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue