From 9781b0f5622c4e3699d4c4e077b16f29720234e8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 21 Jun 2026 09:02:16 +0800 Subject: [PATCH] fix(mysql): normalize unsigned column types --- .../tableStructureEditorState.spec.ts | 17 ++++++++ .../src/lib/tableStructureEditorState.ts | 30 ++++++++++++-- .../src/table_structure_sql/column_format.rs | 41 +++++++++++++++++++ .../dbx-core/src/table_structure_sql/tests.rs | 21 ++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/tableStructureEditorState.spec.ts diff --git a/apps/desktop/src/lib/__tests__/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/tableStructureEditorState.spec.ts new file mode 100644 index 000000000..b4fb11c0f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tableStructureEditorState.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { combineDataTypeForDatabase, splitDataType } from "../tableStructureEditorState"; + +describe("tableStructureEditorState", () => { + it("keeps mysql unsigned attributes in the editable base type", () => { + expect(splitDataType("int(11) unsigned")).toEqual({ baseType: "int unsigned", params: "11" }); + expect(splitDataType("bigint(20) unsigned zerofill")).toEqual({ + baseType: "bigint unsigned zerofill", + params: "20", + }); + }); + + it("combines mysql unsigned type choices with the length field", () => { + expect(combineDataTypeForDatabase("mysql", "int unsigned", "11")).toBe("int(11) unsigned"); + expect(combineDataTypeForDatabase("mysql", "bigint unsigned zerofill", "20")).toBe("bigint(20) unsigned zerofill"); + }); +}); diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index 2944ab2f7..423736451 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -643,8 +643,14 @@ export function splitDataType(raw: string): { baseType: string; params: string } const trimmed = raw.trim(); const parenIdx = trimmed.indexOf("("); if (parenIdx === -1) return { baseType: trimmed, params: "" }; - const baseType = trimmed.slice(0, parenIdx).trim(); - const params = trimmed.slice(parenIdx + 1, trimmed.lastIndexOf(")")).trim(); + const closeIdx = trimmed.lastIndexOf(")"); + const baseTypePrefix = trimmed.slice(0, parenIdx).trim(); + const params = trimmed.slice(parenIdx + 1, closeIdx).trim(); + const suffix = trimmed + .slice(closeIdx + 1) + .trim() + .replace(/\s+/g, " "); + const baseType = /^(?:signed|unsigned|zerofill)(?:\s+(?:signed|unsigned|zerofill))*$/i.test(suffix) ? `${baseTypePrefix} ${suffix}`.trim() : baseTypePrefix; return { baseType, params }; } @@ -660,7 +666,10 @@ export function combineDataTypeForDatabase(dbType: DatabaseType | undefined, bas if (isDataTypeLengthDisabled(dbType, baseType)) { return baseType; } - return combineDataType(baseType, normalizeDataTypeParams(dbType, baseType, params)); + const normalizedParams = normalizeDataTypeParams(dbType, baseType, params); + const mysqlType = combineMysqlNumericAttributeType(dbType, baseType, normalizedParams); + if (mysqlType) return mysqlType; + return combineDataType(baseType, normalizedParams); } export function normalizeDataTypeParams(dbType: DatabaseType | undefined, baseType: string, params: string): string { @@ -701,6 +710,21 @@ function isTemporalPrecisionType(dbType: DatabaseType | undefined, baseType: str } } +function combineMysqlNumericAttributeType(dbType: DatabaseType | undefined, baseType: string, params: string): string | null { + if (!params || !isMysqlLikeStructureType(dbType)) return null; + const parts = baseType.trim().replace(/\s+/g, " ").split(" ").filter(Boolean); + const typeName = parts[0]?.toLowerCase(); + if (!typeName || !["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "real", "double", "float", "decimal", "numeric"].includes(typeName)) return null; + const attrIndex = parts.findIndex((part) => ["signed", "unsigned", "zerofill"].includes(part.toLowerCase())); + if (attrIndex === -1) return null; + if (!parts.slice(attrIndex).every((part) => ["signed", "unsigned", "zerofill"].includes(part.toLowerCase()))) return null; + return `${parts.slice(0, attrIndex).join(" ")}(${params}) ${parts.slice(attrIndex).join(" ")}`; +} + +function isMysqlLikeStructureType(dbType: DatabaseType | undefined): boolean { + return dbType === "mysql" || dbType === "doris" || dbType === "starrocks" || dbType === "goldendb" || dbType === "sundb" || dbType === "databend"; +} + function isValidTemporalPrecision(dbType: DatabaseType | undefined, params: string): boolean { if (!/^\d+$/.test(params)) return false; const value = Number(params); diff --git a/crates/dbx-core/src/table_structure_sql/column_format.rs b/crates/dbx-core/src/table_structure_sql/column_format.rs index 6074f531f..92f19c2d2 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -132,6 +132,12 @@ pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: & return trimmed.to_string(); } + if dialect == StructureDialect::Mysql { + if let Some(normalized) = normalize_mysql_numeric_attribute_type(base_type, params) { + return normalized; + } + } + if is_temporal_precision_type(dialect, base_type) { return if is_valid_temporal_precision(params, dialect) { format!("{base_type}({params})") @@ -143,6 +149,41 @@ pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: & trimmed.to_string() } +fn normalize_mysql_numeric_attribute_type(base_type: &str, params: &str) -> Option { + let mut parts: Vec<&str> = base_type.split_whitespace().collect(); + let type_name = parts.first().copied()?.to_ascii_lowercase(); + if !matches!( + type_name.as_str(), + "tinyint" + | "smallint" + | "mediumint" + | "int" + | "integer" + | "bigint" + | "real" + | "double" + | "float" + | "decimal" + | "numeric" + ) { + return None; + } + let split_index = parts.iter().position(|part| { + let normalized = part.to_ascii_lowercase(); + matches!(normalized.as_str(), "signed" | "unsigned" | "zerofill") + })?; + if !parts[split_index..].iter().all(|part| { + let normalized = part.to_ascii_lowercase(); + matches!(normalized.as_str(), "signed" | "unsigned" | "zerofill") + }) { + return None; + } + + let attrs = parts.split_off(split_index).join(" "); + let base = parts.join(" "); + Some(format!("{base}({params}) {attrs}")) +} + pub(super) fn is_temporal_precision_type(dialect: StructureDialect, base_type: &str) -> bool { let normalized = base_type.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); match dialect { diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index e1ec3863e..18f175f3a 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -117,6 +117,27 @@ fn builds_mysql_column_and_index_changes() { ); } +#[test] +fn builds_mysql_unsigned_integer_column_with_length_before_attribute() { + let mut score = column("score"); + score.data_type = "int unsigned(11)".to_string(); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "users".to_string(), + columns: vec![score], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE `users` ADD COLUMN `score` int(11) unsigned;"]); +} + #[test] fn builds_highgo_foreign_key_changes_with_postgres_syntax() { let mut old_fk = foreign_key("orders_user_id_fkey", "user_id", "users", "id");