🐛 fix(sql): 适配多数据源 SQL 方言生成

- 表设计 DDL 按 Oracle/Dameng、SQL Server、PG-family、SQLite/DuckDB、ClickHouse/TDengine 分支生成

- 新增统一 SQL 方言工具,驱动字段类型候选和 SQL 自动补全

- 修复 Oracle/Dameng DATE/TIMESTAMP 删除条件字面量

- 补充多方言 DDL、补全和 Oracle 删除回归测试

Refs #402

Refs #409
This commit is contained in:
Syngnat
2026-04-26 17:14:07 +08:00
parent f16e2f15c2
commit df4fcab90b
9 changed files with 1497 additions and 163 deletions

View File

@@ -13,6 +13,7 @@ import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
@@ -521,6 +522,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId);
const activeDialect = resolveSqlDialect(
String(activeConnection?.config?.type || ''),
String(activeConnection?.config?.driver || ''),
);
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
const stripQuotes = (ident: string) => {
let raw = (ident || '').trim();
@@ -776,7 +784,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0
&& SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
const sortGroups = shouldBoostKeywords
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsTableName
@@ -878,7 +886,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}));
// 关键字提示
const keywordSuggestions = SQL_KEYWORDS
const keywordSuggestions = dialectKeywords
.filter((k) => startsWithPrefix(k))
.map(k => ({
label: k,
@@ -889,7 +897,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}));
// 内置函数提示
const funcSuggestions = SQL_FUNCTIONS
const funcSuggestions = dialectFunctions
.filter((f) => startsWithPrefix(f.name))
.map(f => ({
label: f.name,

View File

@@ -9,9 +9,19 @@ 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, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
isMysqlFamilyDialect as isMysqlFamilySqlDialect,
isOracleLikeDialect as isOracleLikeSqlDialect,
isPgLikeDialect as isPgLikeSqlDialect,
isSqlServerDialect as isSqlServerSqlDialect,
quoteSqlIdentifierPart,
quoteSqlIdentifierPath,
resolveColumnTypeOptions,
resolveSqlDialect,
} from '../utils/sqlDialect';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -540,6 +550,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// Initial Columns Definition
useEffect(() => {
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
const initialCols = [
{
title: '名',
@@ -556,7 +567,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'type',
width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<AutoComplete options={DB_TYPE_OPTIONS[getDbType()] || COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
<AutoComplete options={columnTypeOptions} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
)
},
{
@@ -636,7 +647,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
}])
];
setTableColumns(initialCols);
}, [readOnly]); // Re-create if readOnly changes
}, [connections, openCommentEditor, readOnly, tab.connectionId]); // Re-create when datasource dialect or readonly state changes
const flushResizeGhost = useCallback(() => {
resizeRafRef.current = null;
@@ -847,16 +858,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId);
const type = normalizeDbType(String(conn?.config?.type || ''));
if (!type) return '';
if (type === 'custom') {
return inferDialectFromCustomDriver(String(conn?.config?.driver || ''));
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
const rawType = String(conn?.config?.type || '').trim();
if (!rawType) return '';
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''));
};
const generateTriggerTemplate = (): string => {
@@ -865,6 +869,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'diros':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW
@@ -897,6 +903,7 @@ BEGIN
-- 触发器逻辑
END;`;
case 'oracle':
case 'dameng':
case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
@@ -922,6 +929,8 @@ END;`;
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'diros':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
@@ -931,6 +940,7 @@ END;`;
case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
case 'oracle':
case 'dameng':
case 'dm':
return `DROP TRIGGER "${triggerName}"`;
case 'sqlite':
@@ -1334,36 +1344,20 @@ ${selectedTrigger.statement}`;
};
};
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm';
const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver';
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean => isPgLikeSqlDialect(dbType);
const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType);
const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType);
const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType);
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine';
const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite';
const quoteIdentifierPartByDialect = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dbType)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
return quoteSqlIdentifierPart(dbType, part);
};
const quoteIdentifierPathByDialect = (path: string, dbType: string): string => {
const raw = String(path || '').trim();
if (!raw) return '';
const parts = raw
.split('.')
.map(part => stripIdentifierQuotes(part))
.filter(Boolean);
if (parts.length === 0) return '';
return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.');
return quoteSqlIdentifierPath(dbType, path);
};
const resolveTableInfo = () => {
@@ -1481,19 +1475,13 @@ ${selectedTrigger.statement}`;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
let extra = curr.extra || "";
if (curr.isAutoIncrement && !extra.toLowerCase().includes('auto_increment')) {
extra += " AUTO_INCREMENT";
}
return `\`${escapeBacktickIdentifier(curr.name)}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${escapeSqlString(String(curr.default))}'` : ''} ${extra} COMMENT '${escapeSqlString(curr.comment || '')}'`;
return buildCreateTablePreviewSql({
dbType: getDbType(),
tableName: targetTableName,
columns: targetColumns,
charset: targetCharset,
collation: targetCollation,
});
const pks = targetColumns.filter(c => c.key === 'PRI').map(c => `\`${escapeBacktickIdentifier(c.name)}\``);
if (pks.length > 0) {
colDefs.push(`PRIMARY KEY (${pks.join(', ')})`);
}
return `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${targetCharset} COLLATE=${targetCollation};`;
};
const openCopySelectedColumnsModal = () => {

View File

@@ -141,6 +141,33 @@ describe('buildCopyInsertSQL', () => {
});
});
it('uses Oracle date constructors when all-column DELETE matching includes DATE values', () => {
const result = buildCopyDeleteSQL({
dbType: 'oracle',
tableName: 'LZJ.RIJIE_TABLE',
orderedCols: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
allTableColumns: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
record: {
NAME: '张三',
CREATED_AT: '2026-04-26T08:30:00+08:00',
STATUS: 'DONE',
MEMO: null,
},
columnTypesByLowerName: {
name: 'NVARCHAR2',
created_at: 'DATE',
status: 'VARCHAR2',
memo: 'VARCHAR2',
},
});
expect(result).toEqual({
ok: true,
whereStrategy: 'all-columns',
sql: `DELETE FROM "LZJ"."RIJIE_TABLE" WHERE ("NAME" = '张三' AND "CREATED_AT" = TO_DATE('2026-04-26 08:30:00', 'YYYY-MM-DD HH24:MI:SS') AND "STATUS" = 'DONE' AND "MEMO" IS NULL);`,
});
});
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
const result = buildCopyDeleteSQL({
dbType: 'mysql',

View File

@@ -1,5 +1,6 @@
import type { IndexDefinition } from '../types';
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type BuildCopyInsertSQLParams = {
dbType: string;
@@ -164,10 +165,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
return String(value);
};
const formatCopySqlLiteral = (value: any, columnType?: string): string => {
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
if (!isTemporalColumnType(columnType)) {
return null;
}
const normalized = toNormalizedLiteralText(value, columnType);
const escaped = escapeLiteral(normalized);
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
}
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
}
const rawType = String(columnType || '').toLowerCase();
if (rawType.includes('timestamp')) {
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
}
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
};
const formatCopySqlLiteral = (value: any, columnType?: string, dbType = ''): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (isOracleLikeDialect(dbType)) {
const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType);
if (oracleTemporalLiteral) {
return oracleTemporalLiteral;
}
}
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
};
@@ -208,7 +235,7 @@ const buildWhereClauseForColumns = ({
predicates.push(`${quotedColumn} IS NULL`);
continue;
}
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`);
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
}
if (predicates.length === 0) {
return null;
@@ -283,7 +310,7 @@ export const buildCopyInsertSQL = ({
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const { value } = getRecordValue(record, col);
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col));
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType);
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
@@ -341,7 +368,7 @@ const buildCopyMutationSQL = (
const assignments = normalizedOrderedCols.map((columnName) => {
const { value } = getRecordValue(record, columnName);
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`;
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`;
});
return {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildCreateTablePreviewSql,
buildAlterTablePreviewSql,
hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput,
@@ -76,4 +77,140 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).toContain('FIRST');
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
});
it('builds oracle alter preview with oracle rename and modify syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'oracle',
tableName: 'HR.EMPLOYEES',
originalColumns: [
baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(64)', nullable: 'YES', comment: '旧名称' }),
],
columns: [
baseColumn({
_key: 'name',
name: 'DISPLAY_NAME',
type: 'VARCHAR2(128)',
nullable: 'NO',
default: 'guest',
comment: '显示名',
}),
],
}));
expect(sql).toContain('ALTER TABLE "HR"."EMPLOYEES"\nRENAME COLUMN "NAME" TO "DISPLAY_NAME";');
expect(sql).toContain(`ALTER TABLE "HR"."EMPLOYEES"\nMODIFY ("DISPLAY_NAME" VARCHAR2(128) DEFAULT 'guest' NOT NULL);`);
expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."DISPLAY_NAME" IS '显示名';`);
expect(sql).not.toContain('`');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('AUTO_INCREMENT');
});
it('builds sqlserver alter preview with sp_rename and alter column syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'sqlserver',
tableName: 'dbo.Users',
originalColumns: [
baseColumn({ _key: 'name', name: 'name', type: 'nvarchar(64)', nullable: 'YES' }),
],
columns: [
baseColumn({ _key: 'name', name: 'display_name', type: 'nvarchar(128)', nullable: 'NO' }),
],
}));
expect(sql).toContain(`EXEC sp_rename 'dbo.Users.name', 'display_name', 'COLUMN';`);
expect(sql).toContain('ALTER TABLE [dbo].[Users]\nALTER COLUMN [display_name] nvarchar(128) NOT NULL;');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
expect(sql).not.toContain('`');
});
it('keeps sqlite alter preview limited to sqlite-supported operations', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'sqlite',
tableName: 'users',
originalColumns: [
baseColumn({ _key: 'name', name: 'name', type: 'TEXT', nullable: 'YES' }),
],
columns: [
baseColumn({ _key: 'name', name: 'display_name', type: 'INTEGER', nullable: 'NO' }),
],
}));
expect(sql).toContain('ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";');
expect(sql).toContain('-- SQLite 不支持直接修改字段属性');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
expect(sql).not.toContain('AFTER');
});
it('builds duckdb alter preview without mysql-only syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'duckdb',
tableName: 'main.users',
originalColumns: [
baseColumn({ _key: 'score', name: 'score', type: 'INTEGER', nullable: 'YES', default: '0' }),
],
columns: [
baseColumn({ _key: 'score', name: 'score', type: 'BIGINT', nullable: 'NO', default: '1' }),
],
}));
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DATA TYPE BIGINT;');
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DEFAULT 1;');
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET NOT NULL;');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
});
it('uses native limited alter syntax for clickhouse and tdengine instead of mysql syntax', () => {
const clickhouseSql = buildAlterTablePreviewSql(buildInput({
dbType: 'clickhouse',
tableName: 'events',
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'String', nullable: 'YES' })],
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'String', nullable: 'YES' })],
}));
const tdengineSql = buildAlterTablePreviewSql(buildInput({
dbType: 'tdengine',
tableName: 'meters',
originalColumns: [baseColumn({ _key: 'value', name: 'value', type: 'FLOAT', nullable: 'YES' })],
columns: [baseColumn({ _key: 'value', name: 'value', type: 'DOUBLE', nullable: 'YES' })],
}));
expect(clickhouseSql).toContain('ALTER TABLE `events`\nRENAME COLUMN `name` TO `display_name`;');
expect(tdengineSql).toContain('ALTER TABLE `meters`\nMODIFY COLUMN `value` DOUBLE;');
expect(clickhouseSql).not.toContain('CHANGE COLUMN');
expect(tdengineSql).not.toContain('CHANGE COLUMN');
expect(clickhouseSql).not.toContain('AFTER');
expect(tdengineSql).not.toContain('AFTER');
});
it('treats mariadb doris and sphinx as mysql-family only where mysql syntax is intended', () => {
for (const dbType of ['mariadb', 'diros', 'sphinx']) {
const sql = buildAlterTablePreviewSql(buildInput({ dbType }));
expect(sql).toContain('ALTER TABLE `users`');
expect(sql).toContain('ADD COLUMN `age` int NULL');
}
});
it('builds oracle create table preview without mysql table options', () => {
const sql = buildCreateTablePreviewSql({
dbType: 'oracle',
tableName: 'HR.EMPLOYEES',
charset: 'utf8mb4',
collation: 'utf8mb4_unicode_ci',
columns: [
baseColumn({ _key: 'id', name: 'ID', type: 'NUMBER(10)', nullable: 'NO', key: 'PRI', isAutoIncrement: true }),
baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(255)', nullable: 'YES', comment: '姓名' }),
],
});
expect(sql).toContain('CREATE TABLE "HR"."EMPLOYEES"');
expect(sql).toContain('"ID" NUMBER(10) GENERATED BY DEFAULT AS IDENTITY NOT NULL');
expect(sql).toContain('PRIMARY KEY ("ID")');
expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."NAME" IS '姓名';`);
expect(sql).not.toContain('ENGINE=InnoDB');
expect(sql).not.toContain('DEFAULT CHARSET');
expect(sql).not.toContain('AUTO_INCREMENT');
expect(sql).not.toContain('`');
});
});

View File

@@ -1,3 +1,16 @@
import {
isBacktickIdentifierDialect,
isMysqlFamilyDialect,
isOracleLikeDialect,
isPgLikeDialect,
isSqlServerDialect,
quoteSqlIdentifierPart,
quoteSqlIdentifierPath,
resolveSqlDialect,
unquoteSqlIdentifierPart,
unquoteSqlIdentifierPath,
} from '../utils/sqlDialect';
export interface EditableColumnSnapshot {
_key: string;
name: string;
@@ -17,21 +30,17 @@ export interface BuildAlterTablePreviewInput {
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, '""');
export interface BuildCreateTablePreviewInput {
dbType: string;
tableName: string;
columns: EditableColumnSnapshot[];
charset?: string;
collation?: string;
}
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 escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const stripIdentifierQuotes = unquoteSqlIdentifierPart;
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
@@ -44,117 +53,158 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
};
};
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const quoteIdentifierPart = (part: string, dbType: string): string => quoteSqlIdentifierPart(dbType, part);
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
const quoteIdentifierPath = (path: string, dbType: string): string => quoteSqlIdentifierPath(dbType, path);
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 normalizeDefaultText = (value: unknown): string => String(value ?? '').trim();
const isKnownDefaultExpression = (trimmed: string): boolean => {
if (!trimmed) return false;
if (/^N?'.*'$/i.test(trimmed)) return true;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return true;
if (/^(true|false|null)$/i.test(trimmed)) return true;
if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) return true;
if (/^(now|uuid|newid|sysdatetime)\s*\(\s*\)$/i.test(trimmed)) return true;
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return true;
return false;
};
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();
const formatDefaultExpression = (value: unknown, dbType: string): string => {
const trimmed = normalizeDefaultText(value);
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)}'`;
if (isKnownDefaultExpression(trimmed)) {
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) {
return trimmed.toUpperCase();
}
return trimmed;
}
const prefix = isSqlServerDialect(dbType) ? 'N' : '';
return `${prefix}'${escapeSqlString(trimmed)}'`;
};
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => {
let extra = String(column.extra || '');
const buildDefaultSql = (value: unknown, dbType: string): string => {
const defaultValue = normalizeDefaultText(value);
if (!defaultValue) return '';
return `DEFAULT ${formatDefaultExpression(defaultValue, dbType)}`;
};
const definitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => (
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
);
const physicalDefinitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => (
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
);
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
let extra = String(column.extra || '').trim();
if (column.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) {
extra += ' AUTO_INCREMENT';
extra = `${extra} AUTO_INCREMENT`.trim();
}
} 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 defaultSql = buildDefaultSql(column.default, dbType);
return [
quoteIdentifierPart(column.name, dbType),
String(column.type || '').trim(),
column.nullable === 'NO' ? 'NOT NULL' : 'NULL',
defaultSql,
extra,
`COMMENT '${escapeSqlString(column.comment || '')}'`,
].filter(Boolean).join(' ').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)}`);
const buildStandardColumnDefinition = (
column: EditableColumnSnapshot,
dbType: string,
options: { includeNull?: boolean; includeIdentity?: boolean } = {},
): string => {
const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()];
if (options.includeIdentity && column.isAutoIncrement) {
if (isSqlServerDialect(dbType)) {
parts.push('IDENTITY(1,1)');
} else if (isOracleLikeDialect(dbType)) {
parts.push('GENERATED BY DEFAULT AS IDENTITY');
}
}
const defaultSql = buildDefaultSql(column.default, dbType);
if (defaultSql) parts.push(defaultSql);
if (column.nullable === 'NO') {
parts.push('NOT NULL');
} else if (options.includeNull) {
parts.push('NULL');
}
return parts.filter(Boolean).join(' ').trim();
};
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()];
const defaultSql = buildDefaultSql(column.default, dbType);
if (defaultSql) parts.push(defaultSql);
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 buildColumnCommentSql = (tableRef: string, columnName: string, comment: string, dbType: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, dbType)}`;
const trimmed = String(comment || '').trim();
if (!trimmed) {
if (!trimmed && isPgLikeDialect(dbType)) {
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 buildSqlServerColumnCommentSql = (
tableName: string,
columnName: string,
comment: string,
): string => {
const { schemaName, objectName } = splitQualifiedName(tableName);
const schema = escapeSqlString(schemaName || 'dbo');
const table = escapeSqlString(objectName || tableName);
const column = escapeSqlString(columnName);
const value = escapeSqlString(comment || '');
return `IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.tables t ON ep.major_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id JOIN sys.columns c ON ep.major_id = c.object_id AND ep.minor_id = c.column_id WHERE ep.name = N'MS_Description' AND s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}') BEGIN EXEC sp_updateextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END ELSE BEGIN EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END;`;
};
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableName = quoteIdentifierPath(input.tableName, dbType);
const alters: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`);
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, dbType)}`);
}
});
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);
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, dbType)}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr, dbType);
if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim());
return;
}
const definitionChanged =
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
curr.default !== orig.default ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
if (curr.name !== orig.name) {
alters.push(
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
);
alters.push(`CHANGE COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${colDef} ${positionSql}`.trim());
return;
}
if (definitionChanged) {
if (definitionChanged(curr, orig)) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
}
});
@@ -163,74 +213,65 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
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 (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'))
.map((col) => quoteIdentifierPart(col.name, dbType))
.join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`);
}
}
if (alters.length === 0) {
return '';
}
return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
};
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableParts = splitQualifiedName(input.tableName);
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
const tableRef = quoteIdentifierPath(input.tableName, 'postgres');
const tableRef = quoteIdentifierPath(input.tableName, dbType);
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')};`);
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
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 || ''));
}
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr, dbType)};`);
if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
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')};`);
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} TYPE ${curr.type};`);
}
const currDefault = String(curr.default || '').trim();
const origDefault = String(orig.default || '').trim();
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} 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'};`,
);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || ''));
statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType));
}
});
@@ -239,12 +280,12 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
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')};`);
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, dbType)};`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'postgres'))
.map((col) => quoteIdentifierPart(col.name, dbType))
.join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
@@ -253,13 +294,322 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = String(input.dbType || '').trim().toLowerCase();
if (isPgLikeDialect(dbType)) {
return buildPgLikeAlterPreviewSql({ ...input, dbType });
const buildOracleLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableRef = quoteIdentifierPath(input.tableName, dbType);
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, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD (${buildStandardColumnDefinition(curr, dbType, { includeIdentity: true })});`);
if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (physicalDefinitionChanged(curr, orig)) {
statements.push(`ALTER TABLE ${tableRef}\nMODIFY (${buildStandardColumnDefinition({ ...curr, name: currentName }, dbType, { includeIdentity: true })});`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType));
}
});
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 PRIMARY KEY;`);
if (newPKKeys.length > 0) {
const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
}
return buildMySqlAlterPreviewSql({ ...input, dbType });
return statements.join('\n');
};
const buildSqlServerDefaultDropBatch = (tableName: string, columnName: string): string => {
const { schemaName, objectName } = splitQualifiedName(tableName);
const schema = escapeSqlString(schemaName || 'dbo');
const table = escapeSqlString(objectName || tableName);
const column = escapeSqlString(columnName);
const tableRef = quoteIdentifierPath(`${schemaName || 'dbo'}.${objectName || tableName}`, 'sqlserver');
return `DECLARE @gonavi_df nvarchar(128); SELECT @gonavi_df = dc.name FROM sys.default_constraints dc JOIN sys.columns c ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}'; IF @gonavi_df IS NOT NULL EXEC(N'ALTER TABLE ${tableRef} DROP CONSTRAINT ' + QUOTENAME(@gonavi_df));`;
};
const buildSqlServerAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'sqlserver';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
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, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD ${buildStandardColumnDefinition(curr, dbType, { includeNull: true, includeIdentity: true })};`);
if (String(curr.comment || '').trim()) statements.push(buildSqlServerColumnCommentSql(input.tableName, curr.name, curr.comment || ''));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
const plainTablePath = unquoteSqlIdentifierPath(input.tableName);
statements.push(`EXEC sp_rename '${escapeSqlString(`${plainTablePath}.${orig.name}`)}', '${escapeSqlString(curr.name)}', 'COLUMN';`);
currentName = curr.name;
}
if (curr.type !== orig.type || curr.nullable !== orig.nullable || Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${buildStandardColumnDefinition({ ...curr, name: currentName, default: '' }, dbType, { includeNull: true, includeIdentity: false })};`);
}
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
statements.push(buildSqlServerDefaultDropBatch(input.tableName, currentName));
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nADD DEFAULT ${formatDefaultExpression(currDefault, dbType)} FOR ${quoteIdentifierPart(currentName, dbType)};`);
}
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildSqlServerColumnCommentSql(input.tableName, 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) {
const { objectName } = splitQualifiedName(input.tableName);
const constraintName = quoteIdentifierPart(`PK_${objectName || 'table'}`, dbType);
if (origPKKeys.length > 0) {
statements.push(`-- SQL Server 删除旧主键需要原约束名;请先在索引页确认后删除。`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkNames});`);
}
}
return statements.join('\n');
};
const buildSqliteAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'sqlite';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
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, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (physicalDefinitionChanged(curr, orig) || (curr.comment || '') !== (orig.comment || '')) {
statements.push(`-- SQLite 不支持直接修改字段属性,请通过创建新表、迁移数据、替换旧表的方式处理字段 ${currentName}`);
}
});
return statements.join('\n');
};
const buildDuckDbAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'duckdb';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
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, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DATA TYPE ${curr.type};`);
}
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`);
}
}
if (curr.nullable !== orig.nullable) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(`-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注,字段 ${currentName} 的备注仅保留在设计器预览中。`);
}
});
return statements.join('\n');
};
const buildLimitedBacktickAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string, label: string): string => {
const tableRef = quoteIdentifierPath(input.tableName, dbType);
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, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${quoteIdentifierPart(curr.name, dbType)} ${curr.type};`);
if (curr.nullable === 'NO' || normalizeDefaultText(curr.default) || String(curr.comment || '').trim()) {
statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`);
}
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nMODIFY COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.type};`);
}
if (
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`);
}
});
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
if (isPgLikeDialect(dbType)) return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType);
if (isOracleLikeDialect(dbType)) return buildOracleLikeAlterPreviewSql({ ...input, dbType }, dbType);
if (isSqlServerDialect(dbType)) return buildSqlServerAlterPreviewSql({ ...input, dbType });
if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType });
if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType });
if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse');
if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine');
if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType);
return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType);
};
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
buildAlterTablePreviewSql(input).trim().length > 0;
const buildCreateTableColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
if (isMysqlFamilyDialect(dbType)) {
return buildMySqlColumnDefinition(column, dbType);
}
if (isOracleLikeDialect(dbType)) {
return buildStandardColumnDefinition(column, dbType, { includeIdentity: true });
}
if (isSqlServerDialect(dbType)) {
return buildStandardColumnDefinition(column, dbType, { includeNull: true, includeIdentity: true });
}
if (dbType === 'clickhouse' || dbType === 'tdengine') {
return [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()].join(' ');
}
return buildStandardColumnDefinition(column, dbType);
};
const buildCreateColumnComments = (tableRef: string, input: BuildCreateTablePreviewInput, dbType: string): string[] => (
input.columns
.filter((column) => String(column.comment || '').trim())
.map((column) => {
if (isSqlServerDialect(dbType)) {
return buildSqlServerColumnCommentSql(input.tableName, column.name, column.comment || '');
}
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) {
return buildColumnCommentSql(tableRef, column.name, column.comment || '', dbType);
}
return '';
})
.filter(Boolean)
);
export const buildCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const colDefs = input.columns.map((column) => buildCreateTableColumnDefinition(column, dbType));
const pkColumns = input.columns.filter((column) => column.key === 'PRI');
if (pkColumns.length > 0) {
const pkNames = pkColumns.map((column) => quoteIdentifierPart(column.name, dbType)).join(', ');
colDefs.push(`PRIMARY KEY (${pkNames})`);
}
const createSql = `CREATE TABLE ${tableRef} (\n ${colDefs.join(',\n ')}\n)`;
const comments = buildCreateColumnComments(tableRef, input, dbType);
if (dbType === 'mysql' || dbType === 'mariadb') {
const charset = String(input.charset || '').trim();
const collation = String(input.collation || '').trim();
const charsetSql = charset ? ` DEFAULT CHARSET=${charset}` : '';
const collationSql = collation ? ` COLLATE=${collation}` : '';
return `${createSql} ENGINE=InnoDB${charsetSql}${collationSql};`;
}
if (dbType === 'clickhouse') {
return `${createSql}\nENGINE = MergeTree\nORDER BY tuple();`;
}
const suffixComments = comments.length > 0 ? `\n${comments.join('\n')}` : '';
if (dbType === 'tdengine' && !input.columns.some((column) => /^timestamp$/i.test(String(column.type || '').trim()))) {
return `${createSql};\n-- TDengine 普通表通常需要 TIMESTAMP 时间列,执行前请确认表模型。${suffixComments}`;
}
if (isBacktickIdentifierDialect(dbType) && dbType !== 'mysql' && dbType !== 'mariadb') {
return `${createSql};${suffixComments}`;
}
return `${createSql};${suffixComments}`;
};