mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-06 14:39:35 +08:00
@@ -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) => ({
|
||||
|
||||
54
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
54
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAlterTablePreviewSql,
|
||||
type BuildAlterTablePreviewInput,
|
||||
type EditableColumnSnapshot,
|
||||
} from './tableDesignerSchemaSql';
|
||||
|
||||
const baseColumn = (overrides: Partial<EditableColumnSnapshot>): 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>): 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');
|
||||
});
|
||||
});
|
||||
255
frontend/src/components/tableDesignerSchemaSql.ts
Normal file
255
frontend/src/components/tableDesignerSchemaSql.ts
Normal file
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user