From c4fe7c8ee8808c7b85106576c04ae6c8554e2b18 Mon Sep 17 00:00:00 2001 From: zipg Date: Sat, 27 Jun 2026 00:53:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PostgreSQL=20?= =?UTF-8?q?=E7=A9=BA=E5=AD=97=E7=AC=A6=E4=B8=B2=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E5=A4=84=E7=90=86=20(#1970)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: staff --- .../src/lib/tableStructureEditorState.ts | 46 ++++++++---- .../dbx-core/src/table_structure_sql/tests.rs | 75 +++++++++++++++++++ .../dbx-core/src/table_structure_sql/util.rs | 74 +++++++++++++++++- .../tableStructureEditorState.test.ts | 42 +++++++++++ 4 files changed, 223 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index 804fa52a3..5d3a6419b 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -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[] { diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 18f175f3a..70300c156 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -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::::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;"]); +} diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index 1167fb7d1..5e00b0767 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -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::>().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() } diff --git a/packages/app-tests/tableStructureEditorState.test.ts b/packages/app-tests/tableStructureEditorState.test.ts index 7928c061a..ec907fbef 100644 --- a/packages/app-tests/tableStructureEditorState.test.ts +++ b/packages/app-tests/tableStructureEditorState.test.ts @@ -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 },