From f696f52470915ab1be3071d84e86fbed2ab67d50 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 11 Apr 2026 21:53:51 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(table-designer):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=87=91=E4=BB=93=E6=96=B0=E5=A2=9E=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #305 --- frontend/src/components/TableDesigner.tsx | 94 ++----- .../components/tableDesignerSchemaSql.test.ts | 54 ++++ .../src/components/tableDesignerSchemaSql.ts | 255 ++++++++++++++++++ 3 files changed, 326 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/tableDesignerSchemaSql.test.ts create mode 100644 frontend/src/components/tableDesignerSchemaSql.ts diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 48c0192..1019eb1 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -9,6 +9,7 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg import { useStore } from '../store'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils'; +import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; interface EditableColumn extends ColumnDefinition { @@ -2118,105 +2119,44 @@ END;`; return; } - const tableName = `\`${isNewTable ? newTableName : tab.tableName}\``; - if (isNewTable) { // CREATE TABLE const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation); setPreviewSql(sql); setIsPreviewOpen(true); } else { - // ALTER TABLE (Existing logic) - const alters: string[] = []; - - originalColumns.forEach(orig => { - if (!columns.find(c => c._key === orig._key)) { - alters.push(`DROP COLUMN \`${orig.name}\``); - } + const tableInfo = resolveTableInfo(); + const sql = buildAlterTablePreviewSql({ + dbType: tableInfo.dbType, + tableName: tableInfo.qualifiedName, + originalColumns, + columns, }); - columns.forEach((curr, index) => { - const orig = originalColumns.find(c => c._key === curr._key); - const prevCol = index > 0 ? columns[index - 1] : null; - const positionSql = prevCol ? `AFTER \`${prevCol.name}\`` : 'FIRST'; - - let extra = curr.extra || ""; - if (curr.isAutoIncrement) { - if (!extra.toLowerCase().includes('auto_increment')) extra += " AUTO_INCREMENT"; - } else { - extra = extra.replace(/auto_increment/gi, "").trim(); - } - - const colDef = `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`; - - if (!orig) { - alters.push(`ADD COLUMN ${colDef} ${positionSql}`); - } else { - const origIndex = originalColumns.findIndex(c => c._key === curr._key); - const origPrevCol = origIndex > 0 ? originalColumns[origIndex - 1] : null; - - let positionChanged = false; - if (index === 0 && origIndex !== 0) positionChanged = true; - if (index > 0 && (!origPrevCol || origPrevCol._key !== prevCol?._key)) positionChanged = true; - - const isNameChanged = orig.name !== curr.name; - const isTypeChanged = orig.type !== curr.type; - const isNullableChanged = orig.nullable !== curr.nullable; - const isDefaultChanged = orig.default !== curr.default; - const isCommentChanged = orig.comment !== curr.comment; - const isAIChanged = orig.isAutoIncrement !== curr.isAutoIncrement; - - if (isNameChanged || isTypeChanged || isNullableChanged || isDefaultChanged || isCommentChanged || positionChanged || isAIChanged) { - if (isNameChanged) { - alters.push(`CHANGE COLUMN \`${orig.name}\` ${colDef} ${positionSql}`); - } else { - alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`); - } - } - } - }); - - const origPKKeys = originalColumns.filter(c => c.key === 'PRI').map(c => c._key); - const newPKKeys = columns.filter(c => c.key === 'PRI').map(c => c._key); - const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every(k => newPKKeys.includes(k)); - - if (keysChanged) { - if (origPKKeys.length > 0) alters.push(`DROP PRIMARY KEY`); - if (newPKKeys.length > 0) { - const pkNames = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``).join(', '); - alters.push(`ADD PRIMARY KEY (${pkNames})`); - } - } - - if (alters.length === 0) { + if (!sql.trim()) { message.info("没有检测到变更"); return; } - - const sql = `ALTER TABLE ${tableName}\n` + alters.join(",\n"); setPreviewSql(sql); setIsPreviewOpen(true); } }; const handleExecuteSave = async () => { - const conn = connections.find(c => c.id === tab.connectionId); - if (!conn) return; - const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; - const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', previewSql); - if (res.success) { - message.success(isNewTable ? "表创建成功!" : "表结构修改成功!"); - setIsPreviewOpen(false); - if (!isNewTable) { + const result = await executeSchemaStatements(previewSql); + if (!result.ok) { + message.error(result.message || "执行失败"); + return; + } + message.success(isNewTable ? "表创建成功!" : "表结构修改成功!"); + setIsPreviewOpen(false); + if (!isNewTable) { fetchData(); } else { // TODO: Close tab or reload sidebar? // Ideally, refresh sidebar node. } - } else { - message.error("执行失败: " + res.message); - } - }; + }; // Merge columns with resize handler const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({ diff --git a/frontend/src/components/tableDesignerSchemaSql.test.ts b/frontend/src/components/tableDesignerSchemaSql.test.ts new file mode 100644 index 0000000..c991f1f --- /dev/null +++ b/frontend/src/components/tableDesignerSchemaSql.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildAlterTablePreviewSql, + type BuildAlterTablePreviewInput, + type EditableColumnSnapshot, +} from './tableDesignerSchemaSql'; + +const baseColumn = (overrides: Partial): EditableColumnSnapshot => ({ + _key: overrides._key || 'col', + name: overrides.name || 'id', + type: overrides.type || 'int', + nullable: overrides.nullable || 'NO', + default: overrides.default || '', + extra: overrides.extra || '', + comment: overrides.comment || '', + key: overrides.key || '', + isAutoIncrement: overrides.isAutoIncrement || false, +}); + +const buildInput = (overrides: Partial): BuildAlterTablePreviewInput => ({ + dbType: overrides.dbType || 'mysql', + tableName: overrides.tableName || 'users', + originalColumns: overrides.originalColumns || [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })], + columns: overrides.columns || [ + baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' }), + baseColumn({ _key: 'age', name: 'age', nullable: 'YES', comment: '年龄' }), + ], +}); + +describe('tableDesignerSchemaSql', () => { + it('keeps mysql alter preview syntax with column position clauses', () => { + const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' })); + + expect(sql).toContain('ALTER TABLE `users`'); + expect(sql).toContain('ADD COLUMN `age` int NULL'); + expect(sql).toContain("COMMENT '年龄'"); + expect(sql).toContain('AFTER `id`'); + }); + + it('builds kingbase alter preview without mysql-only syntax', () => { + const sql = buildAlterTablePreviewSql(buildInput({ + dbType: 'kingbase', + tableName: 'public.users', + })); + + expect(sql).toContain('ALTER TABLE public.users'); + expect(sql).toContain('ADD COLUMN age int'); + expect(sql).toContain("COMMENT ON COLUMN public.users.age IS '年龄';"); + expect(sql).not.toContain('`'); + expect(sql).not.toContain('AFTER'); + expect(sql).not.toContain(' FIRST'); + }); +}); diff --git a/frontend/src/components/tableDesignerSchemaSql.ts b/frontend/src/components/tableDesignerSchemaSql.ts new file mode 100644 index 0000000..c807e6c --- /dev/null +++ b/frontend/src/components/tableDesignerSchemaSql.ts @@ -0,0 +1,255 @@ +export interface EditableColumnSnapshot { + _key: string; + name: string; + type: string; + nullable: string; + default?: string | null; + extra?: string; + comment?: string; + key?: string; + isAutoIncrement?: boolean; +} + +export interface BuildAlterTablePreviewInput { + dbType: string; + tableName: string; + originalColumns: EditableColumnSnapshot[]; + columns: EditableColumnSnapshot[]; +} + +const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); +const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``'); +const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""'); + +const stripIdentifierQuotes = (part: string): string => { + const text = String(part || '').trim(); + if (!text) return ''; + if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1).trim(); + } + if (text.startsWith('[') && text.endsWith(']')) { + return text.slice(1, -1).replace(/]]/g, ']').trim(); + } + return text; +}; + +const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { + const raw = String(qualifiedName || '').trim(); + if (!raw) return { schemaName: '', objectName: '' }; + const idx = raw.lastIndexOf('.'); + if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw }; + return { + schemaName: stripIdentifierQuotes(raw.substring(0, idx)), + objectName: stripIdentifierQuotes(raw.substring(idx + 1)), + }; +}; + +const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql'; +const isPgLikeDialect = (dbType: string): boolean => + dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase'; + +const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident); + +const quoteIdentifierPart = (part: string, dbType: string): string => { + const ident = stripIdentifierQuotes(part); + if (!ident) return ''; + if (isMysqlLikeDialect(dbType)) { + return `\`${escapeBacktickIdentifier(ident)}\``; + } + if (isPgLikeDialect(dbType)) { + if (!needsPgLikeQuote(ident)) { + return ident; + } + return `"${escapeDoubleQuoteIdentifier(ident)}"`; + } + return ident; +}; + +const quoteIdentifierPath = (path: string, dbType: string): string => + String(path || '') + .trim() + .split('.') + .map((part) => stripIdentifierQuotes(part)) + .filter(Boolean) + .map((part) => quoteIdentifierPart(part, dbType)) + .join('.'); + +const formatPgLikeDefault = (value: string): string => { + const trimmed = String(value || '').trim(); + if (!trimmed) return ''; + if (/^'.*'$/.test(trimmed)) return trimmed; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed; + if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase(); + if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase(); + if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed; + return `'${escapeSqlString(trimmed)}'`; +}; + +const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => { + let extra = String(column.extra || ''); + if (column.isAutoIncrement) { + if (!extra.toLowerCase().includes('auto_increment')) { + extra += ' AUTO_INCREMENT'; + } + } else { + extra = extra.replace(/auto_increment/gi, '').trim(); + } + const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : ''; + return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim(); +}; + +const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot): string => { + const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()]; + const defaultValue = String(column.default || '').trim(); + if (defaultValue) { + parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`); + } + if (column.nullable === 'NO') { + parts.push('NOT NULL'); + } + return parts.join(' ').trim(); +}; + +const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => { + const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`; + const trimmed = String(comment || '').trim(); + if (!trimmed) { + return `COMMENT ON COLUMN ${columnRef} IS NULL;`; + } + return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`; +}; + +const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { + const tableName = quoteIdentifierPath(input.tableName, 'mysql'); + const alters: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`); + } + }); + + input.columns.forEach((curr, index) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + const prevCol = index > 0 ? input.columns[index - 1] : null; + const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST'; + const colDef = buildMySqlColumnDefinition(curr); + + if (!orig) { + alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim()); + return; + } + + if ( + curr.name !== orig.name || + curr.type !== orig.type || + curr.nullable !== orig.nullable || + curr.default !== orig.default || + (curr.comment || '') !== (orig.comment || '') || + Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement) + ) { + alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim()); + } + }); + + const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key); + const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key); + const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); + if (keysChanged) { + if (origPKKeys.length > 0) { + alters.push('DROP PRIMARY KEY'); + } + if (newPKKeys.length > 0) { + const pkNames = input.columns + .filter((col) => col.key === 'PRI') + .map((col) => quoteIdentifierPart(col.name, 'mysql')) + .join(', '); + alters.push(`ADD PRIMARY KEY (${pkNames})`); + } + } + + if (alters.length === 0) { + return ''; + } + return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`; +}; + +const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { + const tableParts = splitQualifiedName(input.tableName); + const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName); + const tableRef = quoteIdentifierPath(input.tableName, 'postgres'); + const statements: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, 'postgres')};`); + } + }); + + input.columns.forEach((curr) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + if (!orig) { + statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`); + if (String(curr.comment || '').trim()) { + statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || '')); + } + return; + } + + let currentName = orig.name; + if (curr.name !== orig.name) { + statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, 'postgres')} TO ${quoteIdentifierPart(curr.name, 'postgres')};`); + currentName = curr.name; + } + + if (curr.type !== orig.type) { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`); + } + + const currDefault = String(curr.default || '').trim(); + const origDefault = String(orig.default || '').trim(); + if (currDefault !== origDefault) { + if (currDefault) { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`); + } else { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`); + } + } + + if (curr.nullable !== orig.nullable) { + statements.push( + `ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`, + ); + } + + if ((curr.comment || '') !== (orig.comment || '')) { + statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || '')); + } + }); + + const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key); + const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key); + const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); + if (keysChanged) { + if (origPKKeys.length > 0) { + statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, 'postgres')};`); + } + if (newPKKeys.length > 0) { + const pkNames = input.columns + .filter((col) => col.key === 'PRI') + .map((col) => quoteIdentifierPart(col.name, 'postgres')) + .join(', '); + statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`); + } + } + + return statements.join('\n'); +}; + +export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => { + const dbType = String(input.dbType || '').trim().toLowerCase(); + if (isPgLikeDialect(dbType)) { + return buildPgLikeAlterPreviewSql({ ...input, dbType }); + } + return buildMySqlAlterPreviewSql({ ...input, dbType }); +};