fix: 修复 PostgreSQL 空字符串默认值处理 (#1970)

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipg 2026-06-27 00:53:53 +08:00 committed by GitHub
parent 23b44cc2b9
commit c4fe7c8ee8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 223 additions and 14 deletions

View File

@ -520,20 +520,40 @@ export function applyManticoreDdlColumnExtras(columns: ColumnInfo[], ddl: string
});
}
function isPostgresTextualType(dataType: string): boolean {
const baseType = dataType.split("(")[0]?.trim().replace(/\s+/g, " ").toLowerCase() ?? "";
return ["char", "character", "varchar", "character varying", "text", "bpchar", "name", "json", "jsonb", "xml", "bytea", "uuid"].includes(baseType);
}
function stripPostgresStringDefaultCast(defaultValue: string, dataType: string): string {
if (!isPostgresTextualType(dataType)) return defaultValue;
const trimmed = defaultValue.trim();
const match = trimmed.match(/^('(?:''|[^'])*')::\s*((?:character\s+varying)|character|varchar|char|text|bpchar|name|jsonb?|xml|bytea|uuid)(?:\s*\(\s*\d+\s*\))?$/i);
return match?.[1] ?? defaultValue;
}
function columnDefaultForEditor(column: ColumnInfo, databaseType?: DatabaseType): string {
const defaultValue = column.column_default ?? "";
return databaseType === "postgres" ? stripPostgresStringDefaultCast(defaultValue, column.data_type) : defaultValue;
}
export function createColumnDrafts(columns: ColumnInfo[], databaseType?: DatabaseType): EditableStructureColumn[] {
return columns.map((column, index) => ({
id: `existing:${column.name}`,
name: column.name,
dataType: column.data_type,
isNullable: column.is_nullable,
defaultValue: column.column_default ?? "",
comment: column.comment ?? "",
isPrimaryKey: column.is_primary_key,
extra: parseExtraToColumnExtra(column.extra, databaseType),
original: column,
originalPosition: index,
markedForDrop: false,
}));
return columns.map((column, index) => {
const defaultValue = columnDefaultForEditor(column, databaseType);
return {
id: `existing:${column.name}`,
name: column.name,
dataType: column.data_type,
isNullable: column.is_nullable,
defaultValue,
comment: column.comment ?? "",
isPrimaryKey: column.is_primary_key,
extra: parseExtraToColumnExtra(column.extra, databaseType),
original: { ...column, column_default: column.column_default === null ? null : defaultValue },
originalPosition: index,
markedForDrop: false,
};
});
}
export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[] {

View File

@ -1829,3 +1829,78 @@ fn postgres_varchar_default_is_quoted() {
assert!(result.statements.iter().any(|s| s.contains("SET DEFAULT 'test label'")));
}
#[test]
fn postgres_empty_string_default_is_not_quoted_again() {
let mut col = column("sku");
col.data_type = "character varying".to_string();
col.default_value = "''".to_string();
col.original = Some(ColumnInfo {
name: "sku".to_string(),
data_type: "character varying".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("core".to_string()),
table_name: "products".to_string(),
column: col,
});
assert_eq!(result.statements, vec!["ALTER TABLE \"core\".\"products\" ALTER COLUMN \"sku\" SET DEFAULT '';"]);
}
#[test]
fn postgres_string_default_cast_matches_plain_literal() {
let mut col = column("category");
col.data_type = "character varying".to_string();
col.default_value = "''".to_string();
col.original = Some(ColumnInfo {
name: "category".to_string(),
data_type: "character varying".to_string(),
is_nullable: true,
column_default: Some("''::character varying".to_string()),
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("core".to_string()),
table_name: "products".to_string(),
column: col,
});
assert_eq!(result.statements, Vec::<String>::new());
}
#[test]
fn postgres_integer_default_is_not_quoted() {
let mut col = column("stock");
col.data_type = "integer".to_string();
col.default_value = "0".to_string();
col.original = Some(ColumnInfo {
name: "stock".to_string(),
data_type: "integer".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("core".to_string()),
table_name: "products".to_string(),
column: col,
});
assert_eq!(result.statements, vec!["ALTER TABLE \"core\".\"products\" ALTER COLUMN \"stock\" SET DEFAULT 0;"]);
}

View File

@ -40,6 +40,72 @@ pub(super) fn quote_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn is_sql_string_literal(value: &str) -> bool {
let trimmed = value.trim();
let Some(inner) = trimmed.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')) else {
return false;
};
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\'' && chars.next_if_eq(&'\'').is_none() {
return false;
}
}
true
}
fn postgres_string_default_literal(value: &str) -> Option<&str> {
let trimmed = value.trim();
let inner = trimmed.strip_prefix('\'')?;
let mut literal_end = 1;
let mut chars = inner.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
if ch == '\'' {
if chars.next_if(|(_, next)| *next == '\'').is_some() {
continue;
}
literal_end += index + ch.len_utf8();
break;
}
}
if literal_end == 1 {
return None;
}
let literal = &trimmed[..literal_end];
if !is_sql_string_literal(literal) {
return None;
}
let cast_type = trimmed[literal_end..].trim().strip_prefix("::")?.trim();
if is_postgres_textual_cast_type(cast_type) {
Some(literal)
} else {
None
}
}
fn is_postgres_textual_cast_type(value: &str) -> bool {
let normalized =
value.trim().trim_matches('"').split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
let base_type = normalized.split('(').next().unwrap_or(&normalized).trim();
matches!(
base_type,
"char"
| "character"
| "varchar"
| "character varying"
| "text"
| "bpchar"
| "name"
| "json"
| "jsonb"
| "xml"
| "bytea"
| "uuid"
)
}
pub(super) fn clean(value: &str) -> String {
value.trim().to_string()
}
@ -211,7 +277,11 @@ pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str,
if is_string_type_for_default(dialect, base_type) {
// Only skip quoting for function-call expressions like `gen_random_uuid()`.
// Simple identifiers like `CURRENT_TIMESTAMP` are not valid defaults for string columns.
if default_value.contains('(') || default_value.contains(')') {
if is_sql_string_literal(default_value)
|| (dialect == StructureDialect::Postgres && postgres_string_default_literal(default_value).is_some())
|| default_value.contains('(')
|| default_value.contains(')')
{
return default_value.to_string();
}
return quote_string(default_value);
@ -223,6 +293,8 @@ pub(super) fn normalize_default(value: Option<&String>) -> String {
let trimmed = value.map(|value| value.trim()).unwrap_or("");
if trimmed.eq_ignore_ascii_case("null") {
String::new()
} else if let Some(literal) = postgres_string_default_literal(trimmed) {
literal.to_string()
} else {
trimmed.to_string()
}

View File

@ -92,6 +92,48 @@ test("creates editable column drafts from column metadata", () => {
);
});
test("normalizes PostgreSQL string default casts in editable column drafts", () => {
const drafts = createColumnDrafts(
[
{
name: "category",
data_type: "character varying",
is_nullable: true,
column_default: "''::character varying",
is_primary_key: false,
extra: null,
comment: null,
},
{
name: "status",
data_type: "user_status",
is_nullable: true,
column_default: "'active'::public.user_status",
is_primary_key: false,
extra: null,
comment: null,
},
{
name: "stock",
data_type: "integer",
is_nullable: true,
column_default: "0",
is_primary_key: false,
extra: null,
comment: null,
},
],
"postgres",
);
assert.equal(drafts[0].defaultValue, "''");
assert.equal(drafts[0].original?.column_default, "''");
assert.equal(drafts[1].defaultValue, "'active'::public.user_status");
assert.equal(drafts[1].original?.column_default, "'active'::public.user_status");
assert.equal(drafts[2].defaultValue, "0");
assert.equal(drafts[2].original?.column_default, "0");
});
test("applies manticore column properties from ddl", () => {
const manticoreColumns: ColumnInfo[] = [
{ name: "name", data_type: "string", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null },