🐛 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

@@ -0,0 +1,24 @@
# SQL 方言适配需求进度追踪
## 背景
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
- GitHub 相关问题Refs #402(金仓字段类型/DDL 方言、Refs #409Oracle 删除数据 DATE 字面量)。
## 范围
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
- Oracle/Dameng 数据复制/删除 SQLDATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
## 验证
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
- `npm run build`
## 风险与后续
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER并用中文注释阻止 MySQL 专属子句外溢。
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。

View File

@@ -13,6 +13,7 @@ import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
const SQL_KEYWORDS = [ const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', '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, startColumn: word.startColumn,
endColumn: word.endColumn, 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) => { const stripQuotes = (ident: string) => {
let raw = (ident || '').trim(); 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 expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const shouldBoostKeywords = !expectsTableName const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0 && wordPrefix.length > 0
&& SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
const sortGroups = shouldBoostKeywords const sortGroups = shouldBoostKeywords
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } ? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsTableName : 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)) .filter((k) => startsWithPrefix(k))
.map(k => ({ .map(k => ({
label: 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)) .filter((f) => startsWithPrefix(f.name))
.map(f => ({ .map(f => ({
label: f.name, label: f.name,

View File

@@ -9,9 +9,19 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
import { useStore } from '../store'; import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils'; 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 { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap'; 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 { interface EditableColumn extends ColumnDefinition {
_key: string; _key: string;
@@ -540,6 +550,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// Initial Columns Definition // Initial Columns Definition
useEffect(() => { useEffect(() => {
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
const initialCols = [ const initialCols = [
{ {
title: '名', title: '名',
@@ -556,7 +567,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'type', key: 'type',
width: 150, width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : ( 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); 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(() => { const flushResizeGhost = useCallback(() => {
resizeRafRef.current = null; resizeRafRef.current = null;
@@ -847,16 +858,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const getDbType = (): string => { const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId); const conn = connections.find(c => c.id === tab.connectionId);
const type = normalizeDbType(String(conn?.config?.type || '')); const rawType = String(conn?.config?.type || '').trim();
if (!type) return ''; if (!rawType) return '';
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''));
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 generateTriggerTemplate = (): string => { const generateTriggerTemplate = (): string => {
@@ -865,6 +869,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
switch (dbType) { switch (dbType) {
case 'mysql': case 'mysql':
case 'mariadb':
case 'diros':
return `CREATE TRIGGER trigger_name return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\` BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW FOR EACH ROW
@@ -897,6 +903,7 @@ BEGIN
-- 触发器逻辑 -- 触发器逻辑
END;`; END;`;
case 'oracle': case 'oracle':
case 'dameng':
case 'dm': case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}" BEFORE INSERT ON "${tblName}"
@@ -922,6 +929,8 @@ END;`;
switch (dbType) { switch (dbType) {
case 'mysql': case 'mysql':
case 'mariadb':
case 'diros':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``; return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres': case 'postgres':
case 'kingbase': case 'kingbase':
@@ -931,6 +940,7 @@ END;`;
case 'sqlserver': case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`; return `DROP TRIGGER IF EXISTS [${triggerName}]`;
case 'oracle': case 'oracle':
case 'dameng':
case 'dm': case 'dm':
return `DROP TRIGGER "${triggerName}"`; return `DROP TRIGGER "${triggerName}"`;
case 'sqlite': case 'sqlite':
@@ -1334,36 +1344,20 @@ ${selectedTrigger.statement}`;
}; };
}; };
const isPgLikeDialect = (dbType: string): boolean => const isPgLikeDialect = (dbType: string): boolean => isPgLikeSqlDialect(dbType);
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase'; const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType);
const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm'; const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType);
const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver'; const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType);
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb'; const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine'; const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine';
const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite'; const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite';
const quoteIdentifierPartByDialect = (part: string, dbType: string): string => { const quoteIdentifierPartByDialect = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part); return quoteSqlIdentifierPart(dbType, part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dbType)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
}; };
const quoteIdentifierPathByDialect = (path: string, dbType: string): string => { const quoteIdentifierPathByDialect = (path: string, dbType: string): string => {
const raw = String(path || '').trim(); return quoteSqlIdentifierPath(dbType, path);
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('.');
}; };
const resolveTableInfo = () => { const resolveTableInfo = () => {
@@ -1481,19 +1475,13 @@ ${selectedTrigger.statement}`;
}; };
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``; return buildCreateTablePreviewSql({
const colDefs = targetColumns.map(curr => { dbType: getDbType(),
let extra = curr.extra || ""; tableName: targetTableName,
if (curr.isAutoIncrement && !extra.toLowerCase().includes('auto_increment')) { columns: targetColumns,
extra += " AUTO_INCREMENT"; charset: targetCharset,
} collation: targetCollation,
return `\`${escapeBacktickIdentifier(curr.name)}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${escapeSqlString(String(curr.default))}'` : ''} ${extra} COMMENT '${escapeSqlString(curr.comment || '')}'`;
}); });
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 = () => { 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', () => { it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
const result = buildCopyDeleteSQL({ const result = buildCopyDeleteSQL({
dbType: 'mysql', dbType: 'mysql',

View File

@@ -1,5 +1,6 @@
import type { IndexDefinition } from '../types'; import type { IndexDefinition } from '../types';
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type BuildCopyInsertSQLParams = { type BuildCopyInsertSQLParams = {
dbType: string; dbType: string;
@@ -164,10 +165,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
return String(value); 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) { if (value === null || value === undefined) {
return 'NULL'; return 'NULL';
} }
if (isOracleLikeDialect(dbType)) {
const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType);
if (oracleTemporalLiteral) {
return oracleTemporalLiteral;
}
}
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`; return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
}; };
@@ -208,7 +235,7 @@ const buildWhereClauseForColumns = ({
predicates.push(`${quotedColumn} IS NULL`); predicates.push(`${quotedColumn} IS NULL`);
continue; continue;
} }
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`); predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
} }
if (predicates.length === 0) { if (predicates.length === 0) {
return null; return null;
@@ -283,7 +310,7 @@ export const buildCopyInsertSQL = ({
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col)); const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => { const values = orderedCols.map((col) => {
const { value } = getRecordValue(record, 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(', ')});`; return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
@@ -341,7 +368,7 @@ const buildCopyMutationSQL = (
const assignments = normalizedOrderedCols.map((columnName) => { const assignments = normalizedOrderedCols.map((columnName) => {
const { value } = getRecordValue(record, 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 { return {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildCreateTablePreviewSql,
buildAlterTablePreviewSql, buildAlterTablePreviewSql,
hasAlterTableDraftChanges, hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput, type BuildAlterTablePreviewInput,
@@ -76,4 +77,140 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).toContain('FIRST'); expect(sql).toContain('FIRST');
expect(sql).not.toContain('MODIFY COLUMN `display_name`'); 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 { export interface EditableColumnSnapshot {
_key: string; _key: string;
name: string; name: string;
@@ -17,21 +30,17 @@ export interface BuildAlterTablePreviewInput {
columns: EditableColumnSnapshot[]; columns: EditableColumnSnapshot[];
} }
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); export interface BuildCreateTablePreviewInput {
const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``'); dbType: string;
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""'); tableName: string;
columns: EditableColumnSnapshot[];
charset?: string;
collation?: string;
}
const stripIdentifierQuotes = (part: string): string => { const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const text = String(part || '').trim();
if (!text) return ''; const stripIdentifierQuotes = unquoteSqlIdentifierPart;
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 splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim(); const raw = String(qualifiedName || '').trim();
@@ -44,117 +53,158 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
}; };
}; };
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql'; const quoteIdentifierPart = (part: string, dbType: string): string => quoteSqlIdentifierPart(dbType, part);
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 quoteIdentifierPath = (path: string, dbType: string): string => quoteSqlIdentifierPath(dbType, path);
const quoteIdentifierPart = (part: string, dbType: string): string => { const normalizeDefaultText = (value: unknown): string => String(value ?? '').trim();
const ident = stripIdentifierQuotes(part);
if (!ident) return ''; const isKnownDefaultExpression = (trimmed: string): boolean => {
if (isMysqlLikeDialect(dbType)) { if (!trimmed) return false;
return `\`${escapeBacktickIdentifier(ident)}\``; if (/^N?'.*'$/i.test(trimmed)) return true;
} if (/^-?\d+(\.\d+)?$/.test(trimmed)) return true;
if (isPgLikeDialect(dbType)) { if (/^(true|false|null)$/i.test(trimmed)) return true;
if (!needsPgLikeQuote(ident)) { if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) return true;
return ident; if (/^(now|uuid|newid|sysdatetime)\s*\(\s*\)$/i.test(trimmed)) return true;
} if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return true;
return `"${escapeDoubleQuoteIdentifier(ident)}"`; return false;
}
return ident;
}; };
const quoteIdentifierPath = (path: string, dbType: string): string => const formatDefaultExpression = (value: unknown, dbType: string): string => {
String(path || '') const trimmed = normalizeDefaultText(value);
.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 (!trimmed) return '';
if (/^'.*'$/.test(trimmed)) return trimmed; if (isKnownDefaultExpression(trimmed)) {
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed; if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase(); if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) {
if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase(); return trimmed.toUpperCase();
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed; }
return `'${escapeSqlString(trimmed)}'`; return trimmed;
}
const prefix = isSqlServerDialect(dbType) ? 'N' : '';
return `${prefix}'${escapeSqlString(trimmed)}'`;
}; };
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => { const buildDefaultSql = (value: unknown, dbType: string): string => {
let extra = String(column.extra || ''); 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 (column.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) { if (!extra.toLowerCase().includes('auto_increment')) {
extra += ' AUTO_INCREMENT'; extra = `${extra} AUTO_INCREMENT`.trim();
} }
} else { } else {
extra = extra.replace(/auto_increment/gi, '').trim(); extra = extra.replace(/auto_increment/gi, '').trim();
} }
const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : ''; const defaultSql = buildDefaultSql(column.default, dbType);
return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim(); 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 buildStandardColumnDefinition = (
const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()]; column: EditableColumnSnapshot,
const defaultValue = String(column.default || '').trim(); dbType: string,
if (defaultValue) { options: { includeNull?: boolean; includeIdentity?: boolean } = {},
parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`); ): 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') { if (column.nullable === 'NO') {
parts.push('NOT NULL'); 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(); return parts.join(' ').trim();
}; };
const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => { const buildColumnCommentSql = (tableRef: string, columnName: string, comment: string, dbType: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`; const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, dbType)}`;
const trimmed = String(comment || '').trim(); const trimmed = String(comment || '').trim();
if (!trimmed) { if (!trimmed && isPgLikeDialect(dbType)) {
return `COMMENT ON COLUMN ${columnRef} IS NULL;`; return `COMMENT ON COLUMN ${columnRef} IS NULL;`;
} }
return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`; return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`;
}; };
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { const buildSqlServerColumnCommentSql = (
const tableName = quoteIdentifierPath(input.tableName, 'mysql'); 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[] = []; const alters: string[] = [];
input.originalColumns.forEach((orig) => { input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) { 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) => { input.columns.forEach((curr, index) => {
const orig = input.originalColumns.find((col) => col._key === curr._key); const orig = input.originalColumns.find((col) => col._key === curr._key);
const prevCol = index > 0 ? input.columns[index - 1] : null; const prevCol = index > 0 ? input.columns[index - 1] : null;
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST'; const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, dbType)}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr); const colDef = buildMySqlColumnDefinition(curr, dbType);
if (!orig) { if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim()); alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim());
return; 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) { if (curr.name !== orig.name) {
alters.push( alters.push(`CHANGE COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${colDef} ${positionSql}`.trim());
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
);
return; return;
} }
if (definitionChanged) { if (definitionChanged(curr, orig)) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim()); 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 newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) { if (keysChanged) {
if (origPKKeys.length > 0) { if (origPKKeys.length > 0) alters.push('DROP PRIMARY KEY');
alters.push('DROP PRIMARY KEY');
}
if (newPKKeys.length > 0) { if (newPKKeys.length > 0) {
const pkNames = input.columns const pkNames = input.columns
.filter((col) => col.key === 'PRI') .filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'mysql')) .map((col) => quoteIdentifierPart(col.name, dbType))
.join(', '); .join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`); alters.push(`ADD PRIMARY KEY (${pkNames})`);
} }
} }
if (alters.length === 0) { return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
return '';
}
return `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 tableParts = splitQualifiedName(input.tableName);
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName); const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
const tableRef = quoteIdentifierPath(input.tableName, 'postgres'); const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = []; const statements: string[] = [];
input.originalColumns.forEach((orig) => { input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) { 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) => { input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key); const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) { if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`); statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr, dbType)};`);
if (String(curr.comment || '').trim()) { if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || ''));
}
return; return;
} }
let currentName = orig.name; let currentName = orig.name;
if (curr.name !== 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; currentName = curr.name;
} }
if (curr.type !== orig.type) { 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 currDefault = normalizeDefaultText(curr.default);
const origDefault = String(orig.default || '').trim(); const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) { if (currDefault !== origDefault) {
if (currDefault) { 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 { } 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) { if (curr.nullable !== orig.nullable) {
statements.push( statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`,
);
} }
if ((curr.comment || '') !== (orig.comment || '')) { 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)); const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) { if (keysChanged) {
if (origPKKeys.length > 0) { 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) { if (newPKKeys.length > 0) {
const pkNames = input.columns const pkNames = input.columns
.filter((col) => col.key === 'PRI') .filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'postgres')) .map((col) => quoteIdentifierPart(col.name, dbType))
.join(', '); .join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`); statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
} }
@@ -253,13 +294,322 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
return statements.join('\n'); return statements.join('\n');
}; };
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => { const buildOracleLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const dbType = String(input.dbType || '').trim().toLowerCase(); const tableRef = quoteIdentifierPath(input.tableName, dbType);
if (isPgLikeDialect(dbType)) { const statements: string[] = [];
return buildPgLikeAlterPreviewSql({ ...input, dbType });
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 => export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
buildAlterTablePreviewSql(input).trim().length > 0; 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}`;
};

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
isMysqlFamilyDialect,
resolveColumnTypeOptions,
resolveSqlDialect,
resolveSqlFunctions,
resolveSqlKeywords,
} from './sqlDialect';
const values = (options: Array<{ value: string }>) => options.map((item) => item.value);
const names = (items: Array<{ name: string }>) => items.map((item) => item.name);
describe('sqlDialect', () => {
it('normalizes datasource aliases without collapsing all dialects to mysql', () => {
expect(resolveSqlDialect('postgresql')).toBe('postgres');
expect(resolveSqlDialect('doris')).toBe('diros');
expect(resolveSqlDialect('dameng')).toBe('dameng');
expect(resolveSqlDialect('custom', 'kingbase8')).toBe('kingbase');
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
expect(resolveSqlDialect('custom', 'mariadb')).toBe('mariadb');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
expect(isMysqlFamilyDialect('oracle')).toBe(false);
});
it('resolves field type options per datasource family', () => {
expect(values(resolveColumnTypeOptions('oracle'))).toContain('VARCHAR2(255)');
expect(values(resolveColumnTypeOptions('oracle'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('dameng'))).toContain('VARCHAR2(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).toContain('integer');
expect(values(resolveColumnTypeOptions('kingbase'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('diros'))).toContain('LARGEINT');
expect(values(resolveColumnTypeOptions('sphinx'))).toContain('text');
expect(values(resolveColumnTypeOptions('clickhouse'))).toContain('DateTime64(3)');
expect(values(resolveColumnTypeOptions('tdengine'))).toContain('TIMESTAMP');
expect(values(resolveColumnTypeOptions('duckdb'))).toContain('STRUCT');
});
it('resolves oracle completion keywords and functions without mysql-only suggestions', () => {
expect(resolveSqlKeywords('oracle')).toEqual(expect.arrayContaining(['ROWNUM', 'FETCH', 'VARCHAR2', 'NUMBER']));
expect(resolveSqlKeywords('oracle')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE', 'LIMIT']));
expect(names(resolveSqlFunctions('oracle'))).toEqual(expect.arrayContaining(['NVL', 'SYSDATE', 'TO_DATE']));
expect(names(resolveSqlFunctions('oracle'))).not.toEqual(expect.arrayContaining(['DATE_FORMAT', 'GROUP_CONCAT']));
});
it('resolves mysql-family completion keywords and functions with mysql syntax', () => {
expect(resolveSqlKeywords('mariadb')).toEqual(expect.arrayContaining(['LIMIT', 'CHANGE', 'AUTO_INCREMENT']));
expect(names(resolveSqlFunctions('diros'))).toEqual(expect.arrayContaining(['DATE_FORMAT', 'GROUP_CONCAT']));
});
it('resolves sqlserver completion without mysql-only ddl tokens', () => {
expect(resolveSqlKeywords('sqlserver')).toEqual(expect.arrayContaining(['TOP', 'IDENTITY', 'NVARCHAR']));
expect(resolveSqlKeywords('sqlserver')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE']));
expect(names(resolveSqlFunctions('sqlserver'))).toEqual(expect.arrayContaining(['GETDATE', 'ISNULL', 'NEWID']));
expect(names(resolveSqlFunctions('sqlserver'))).not.toEqual(expect.arrayContaining(['GROUP_CONCAT']));
});
});

View File

@@ -0,0 +1,715 @@
export type ColumnTypeOption = { value: string };
export type SqlFunctionCompletion = {
name: string;
detail: string;
};
export type SqlDialect =
| 'mysql'
| 'mariadb'
| 'diros'
| 'sphinx'
| 'postgres'
| 'kingbase'
| 'highgo'
| 'vastbase'
| 'oracle'
| 'dameng'
| 'sqlserver'
| 'sqlite'
| 'duckdb'
| 'clickhouse'
| 'tdengine'
| 'mongodb'
| 'redis'
| 'unknown'
| string;
const unique = <T>(items: T[]): T[] => Array.from(new Set(items));
const optionValues = (values: string[]): ColumnTypeOption[] => values.map((value) => ({ value }));
const normalizeRawDialect = (value: string): string => String(value || '').trim().toLowerCase();
export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect => {
const normalized = normalizeRawDialect(rawType);
const driver = normalizeRawDialect(rawDriver);
const source = normalized === 'custom' ? driver : normalized;
if (!source) return 'unknown';
switch (source) {
case 'postgresql':
case 'postgres':
case 'pg':
case 'pq':
case 'pgx':
return 'postgres';
case 'mssql':
case 'sql_server':
case 'sql-server':
return 'sqlserver';
case 'doris':
case 'diros':
return 'diros';
case 'dm':
case 'dm8':
case 'dameng':
return 'dameng';
case 'sqlite3':
case 'sqlite':
return 'sqlite';
case 'sphinxql':
return 'sphinx';
case 'kingbase8':
case 'kingbasees':
case 'kingbasev8':
return 'kingbase';
case 'mariadb':
case 'mysql':
case 'sphinx':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'oracle':
case 'duckdb':
case 'clickhouse':
case 'tdengine':
case 'mongodb':
case 'redis':
return source;
default:
break;
}
if (source.includes('postgres')) return 'postgres';
if (source.includes('mariadb')) return 'mariadb';
if (source.includes('mysql')) return 'mysql';
if (source.includes('doris') || source.includes('diros')) return 'diros';
if (source.includes('sphinx')) return 'sphinx';
if (source.includes('kingbase')) return 'kingbase';
if (source.includes('highgo')) return 'highgo';
if (source.includes('vastbase')) return 'vastbase';
if (source.includes('oracle')) return 'oracle';
if (source.includes('dameng') || source.includes('dm8')) return 'dameng';
if (source.includes('sqlite')) return 'sqlite';
if (source.includes('duckdb')) return 'duckdb';
if (source.includes('clickhouse')) return 'clickhouse';
if (source.includes('tdengine')) return 'tdengine';
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
return source;
};
export const isMysqlFamilyDialect = (dbType: string): boolean => (
['mysql', 'mariadb', 'diros', 'sphinx', 'tidb', 'oceanbase', 'starrocks'].includes(resolveSqlDialect(dbType))
);
export const isPgLikeDialect = (dbType: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase'].includes(resolveSqlDialect(dbType))
);
export const isOracleLikeDialect = (dbType: string): boolean => (
['oracle', 'dameng', 'dm'].includes(resolveSqlDialect(dbType))
);
export const isSqlServerDialect = (dbType: string): boolean => resolveSqlDialect(dbType) === 'sqlserver';
export const isBacktickIdentifierDialect = (dbType: string): boolean => (
isMysqlFamilyDialect(dbType) || ['clickhouse', 'tdengine'].includes(resolveSqlDialect(dbType))
);
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 escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``');
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""');
const escapeBracketIdentifier = (value: string) => String(value || '').replace(/]/g, ']]');
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
export const unquoteSqlIdentifierPart = stripIdentifierQuotes;
export const unquoteSqlIdentifierPath = (path: string): string => (
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.join('.')
);
export const quoteSqlIdentifierPart = (dbType: string, part: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
const dialect = resolveSqlDialect(dbType);
if (isBacktickIdentifierDialect(dialect)) {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dialect)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
if (isPgLikeDialect(dialect)) {
return needsPgLikeQuote(ident) ? `"${escapeDoubleQuoteIdentifier(ident)}"` : ident;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
};
export const quoteSqlIdentifierPath = (dbType: string, path: string): string => (
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.map((part) => quoteSqlIdentifierPart(dbType, part))
.join('.')
);
const MYSQL_TYPES = optionValues([
'tinyint',
'tinyint(1)',
'smallint',
'mediumint',
'int',
'bigint',
'float',
'double',
'decimal(10,2)',
'char(50)',
'varchar(255)',
'tinytext',
'text',
'mediumtext',
'longtext',
'binary(255)',
'varbinary(255)',
'tinyblob',
'blob',
'mediumblob',
'longblob',
'date',
'time',
'datetime',
'timestamp',
'year',
'json',
'enum',
'set',
'bit(1)',
]);
const PG_TYPES = optionValues([
'smallint',
'integer',
'bigint',
'real',
'double precision',
'numeric(10,2)',
'serial',
'bigserial',
'char(50)',
'varchar(255)',
'text',
'boolean',
'date',
'time',
'timestamp',
'timestamptz',
'interval',
'bytea',
'json',
'jsonb',
'uuid',
'inet',
'cidr',
'macaddr',
'xml',
'int4range',
'tsquery',
'tsvector',
]);
const SQLSERVER_TYPES = optionValues([
'tinyint',
'smallint',
'int',
'bigint',
'float',
'real',
'decimal(10,2)',
'numeric(10,2)',
'money',
'smallmoney',
'char(50)',
'varchar(255)',
'varchar(max)',
'nchar(50)',
'nvarchar(255)',
'nvarchar(max)',
'text',
'ntext',
'date',
'time',
'datetime',
'datetime2',
'datetimeoffset',
'smalldatetime',
'binary(255)',
'varbinary(255)',
'varbinary(max)',
'image',
'bit',
'uniqueidentifier',
'xml',
]);
const SQLITE_TYPES = optionValues(['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC']);
const ORACLE_TYPES = optionValues([
'NUMBER(10)',
'NUMBER(10,2)',
'FLOAT',
'BINARY_FLOAT',
'BINARY_DOUBLE',
'CHAR(50)',
'VARCHAR2(255)',
'NVARCHAR2(255)',
'CLOB',
'NCLOB',
'BLOB',
'DATE',
'TIMESTAMP',
'TIMESTAMP WITH TIME ZONE',
'RAW(255)',
'LONG RAW',
'XMLTYPE',
]);
const DAMENG_TYPES = optionValues([
'INT',
'BIGINT',
'NUMBER(10)',
'NUMBER(10,2)',
'DECIMAL(10,2)',
'CHAR(50)',
'VARCHAR(255)',
'VARCHAR2(255)',
'NVARCHAR2(255)',
'TEXT',
'CLOB',
'BLOB',
'DATE',
'TIME',
'TIMESTAMP',
'BIT',
]);
const DORIS_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
'SMALLINT',
'INT',
'BIGINT',
'LARGEINT',
'FLOAT',
'DOUBLE',
'DECIMAL(10,2)',
'CHAR(50)',
'VARCHAR(255)',
'STRING',
'DATE',
'DATETIME',
'JSON',
'HLL',
'BITMAP',
'ARRAY<INT>',
'MAP<STRING,STRING>',
'STRUCT<name:STRING>',
]);
const SPHINX_TYPES = optionValues([
'text',
'string',
'integer',
'bigint',
'float',
'bool',
'timestamp',
'json',
]);
const CLICKHOUSE_TYPES = optionValues([
'Int8',
'UInt8',
'Int16',
'UInt16',
'Int32',
'UInt32',
'Int64',
'UInt64',
'Float32',
'Float64',
'Decimal(10,2)',
'String',
'FixedString(32)',
'Date',
'Date32',
'DateTime',
'DateTime64(3)',
'UUID',
'IPv4',
'IPv6',
'Array(String)',
'Nullable(String)',
'LowCardinality(String)',
"Enum8('A'=1)",
]);
const TDENGINE_TYPES = optionValues([
'TIMESTAMP',
'BOOL',
'TINYINT',
'SMALLINT',
'INT',
'BIGINT',
'FLOAT',
'DOUBLE',
'BINARY(255)',
'NCHAR(255)',
'VARBINARY(255)',
'JSON',
'GEOMETRY',
]);
const DUCKDB_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
'SMALLINT',
'INTEGER',
'BIGINT',
'UTINYINT',
'USMALLINT',
'UINTEGER',
'UBIGINT',
'REAL',
'DOUBLE',
'DECIMAL(10,2)',
'VARCHAR',
'BLOB',
'DATE',
'TIME',
'TIMESTAMP',
'TIMESTAMPTZ',
'INTERVAL',
'UUID',
'JSON',
'STRUCT',
'LIST',
'MAP',
]);
const COMMON_TYPES = optionValues(['int', 'varchar(255)', 'text', 'datetime', 'decimal(10,2)', 'bigint', 'json']);
export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'mariadb' || dialect === 'mysql') return MYSQL_TYPES;
if (dialect === 'diros') return DORIS_TYPES;
if (dialect === 'sphinx') return SPHINX_TYPES;
if (isPgLikeDialect(dialect)) return PG_TYPES;
if (dialect === 'oracle') return ORACLE_TYPES;
if (dialect === 'dameng') return DAMENG_TYPES;
if (dialect === 'sqlserver') return SQLSERVER_TYPES;
if (dialect === 'sqlite') return SQLITE_TYPES;
if (dialect === 'duckdb') return DUCKDB_TYPES;
if (dialect === 'clickhouse') return CLICKHOUSE_TYPES;
if (dialect === 'tdengine') return TDENGINE_TYPES;
return COMMON_TYPES;
};
const COMMON_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'HAVING', 'AS', 'AND', 'OR', 'NOT',
'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD',
'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT',
'COMMENT', 'EXPLAIN', 'DISTINCT', 'UNION', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
];
const MYSQL_KEYWORDS = [
'LIMIT', 'OFFSET', 'MODIFY', 'CHANGE', 'AUTO_INCREMENT', 'SHOW', 'DESCRIBE',
'DESC', 'ENGINE', 'CHARSET', 'COLLATE', 'REPLACE', 'DUPLICATE KEY', 'LOCK',
];
const PG_KEYWORDS = [
'LIMIT', 'OFFSET', 'RETURNING', 'SERIAL', 'BIGSERIAL', 'BOOLEAN', 'JSONB',
'ILIKE', 'RENAME', 'TYPE', 'CASCADE', 'RESTRICT', 'ONLY',
];
const ORACLE_KEYWORDS = [
'ROWNUM', 'FETCH', 'FIRST', 'ROWS', 'ONLY', 'VARCHAR2', 'NVARCHAR2', 'NUMBER',
'DATE', 'TIMESTAMP', 'CLOB', 'BLOB', 'SEQUENCE', 'SYNONYM', 'MERGE', 'MINUS',
'CONNECT BY', 'START WITH', 'MODIFY', 'RENAME',
];
const SQLSERVER_KEYWORDS = [
'TOP', 'OFFSET', 'FETCH', 'NEXT', 'ROWS', 'ONLY', 'IDENTITY', 'NVARCHAR',
'DATETIME2', 'BIT', 'GO', 'EXEC', 'PROCEDURE', 'WITH', 'NOLOCK', 'MERGE',
];
const SQLITE_KEYWORDS = ['LIMIT', 'OFFSET', 'AUTOINCREMENT', 'PRAGMA', 'WITHOUT', 'ROWID', 'RENAME'];
const DUCKDB_KEYWORDS = ['LIMIT', 'OFFSET', 'SAMPLE', 'QUALIFY', 'STRUCT', 'LIST', 'MAP', 'JSON', 'UNNEST'];
const CLICKHOUSE_KEYWORDS = [
'LIMIT', 'OFFSET', 'FORMAT', 'ENGINE', 'PARTITION', 'ORDER BY', 'PRIMARY KEY',
'SAMPLE', 'MATERIALIZED', 'ALIAS', 'SETTINGS', 'TTL', 'CODEC',
];
const TDENGINE_KEYWORDS = ['LIMIT', 'SLIMIT', 'SOFFSET', 'TAGS', 'USING', 'INTERVAL', 'FILL', 'PARTITION BY'];
export const resolveSqlKeywords = (dbType: string): string[] => {
const dialect = resolveSqlDialect(dbType);
if (isMysqlFamilyDialect(dialect)) return unique([...COMMON_KEYWORDS, ...MYSQL_KEYWORDS]);
if (isPgLikeDialect(dialect)) return unique([...COMMON_KEYWORDS, ...PG_KEYWORDS]);
if (isOracleLikeDialect(dialect)) return unique([...COMMON_KEYWORDS, ...ORACLE_KEYWORDS]);
if (dialect === 'sqlserver') return unique([...COMMON_KEYWORDS, ...SQLSERVER_KEYWORDS]);
if (dialect === 'sqlite') return unique([...COMMON_KEYWORDS, ...SQLITE_KEYWORDS]);
if (dialect === 'duckdb') return unique([...COMMON_KEYWORDS, ...DUCKDB_KEYWORDS]);
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
return COMMON_KEYWORDS;
};
const fn = (name: string, detail: string): SqlFunctionCompletion => ({ name, detail });
const COMMON_FUNCTIONS = [
fn('COUNT', '聚合 - 计数'),
fn('SUM', '聚合 - 求和'),
fn('AVG', '聚合 - 平均值'),
fn('MAX', '聚合 - 最大值'),
fn('MIN', '聚合 - 最小值'),
fn('CONCAT', '字符串 - 拼接'),
fn('SUBSTRING', '字符串 - 截取子串'),
fn('SUBSTR', '字符串 - 截取子串'),
fn('LENGTH', '字符串 - 长度'),
fn('UPPER', '字符串 - 转大写'),
fn('LOWER', '字符串 - 转小写'),
fn('TRIM', '字符串 - 去空格'),
fn('LTRIM', '字符串 - 去左空格'),
fn('RTRIM', '字符串 - 去右空格'),
fn('REPLACE', '字符串 - 替换'),
fn('ABS', '数学 - 绝对值'),
fn('CEIL', '数学 - 向上取整'),
fn('CEILING', '数学 - 向上取整'),
fn('FLOOR', '数学 - 向下取整'),
fn('ROUND', '数学 - 四舍五入'),
fn('MOD', '数学 - 取模'),
fn('POWER', '数学 - 幂运算'),
fn('SQRT', '数学 - 平方根'),
fn('LOG', '数学 - 对数'),
fn('EXP', '数学 - e 的次方'),
fn('COALESCE', '条件 - 返回第一个非 NULL'),
fn('NULLIF', '条件 - 相等返回 NULL'),
fn('CAST', '转换 - 类型转换'),
fn('CONVERT', '转换 - 类型转换'),
fn('ROW_NUMBER', '窗口 - 行号'),
fn('RANK', '窗口 - 排名'),
fn('DENSE_RANK', '窗口 - 连续排名'),
fn('LAG', '窗口 - 前一行'),
fn('LEAD', '窗口 - 后一行'),
fn('FIRST_VALUE', '窗口 - 第一个值'),
fn('LAST_VALUE', '窗口 - 最后一个值'),
];
const MYSQL_FUNCTIONS = [
fn('GROUP_CONCAT', 'MySQL - 分组拼接'),
fn('CONCAT_WS', 'MySQL - 带分隔符拼接'),
fn('LEFT', 'MySQL - 从左截取'),
fn('RIGHT', 'MySQL - 从右截取'),
fn('CHAR_LENGTH', 'MySQL - 字符长度'),
fn('REVERSE', 'MySQL - 字符串反转'),
fn('REPEAT', 'MySQL - 重复字符串'),
fn('LPAD', 'MySQL - 左填充'),
fn('RPAD', 'MySQL - 右填充'),
fn('INSTR', 'MySQL - 查找位置'),
fn('LOCATE', 'MySQL - 查找位置'),
fn('FIND_IN_SET', 'MySQL - 集合查找'),
fn('FORMAT', 'MySQL - 数字格式化'),
fn('TRUNCATE', 'MySQL - 截断小数'),
fn('RAND', 'MySQL - 随机数'),
fn('POW', 'MySQL - 幂运算'),
fn('LOG2', 'MySQL - 以 2 为底对数'),
fn('LOG10', 'MySQL - 以 10 为底对数'),
fn('NOW', 'MySQL - 当前日期时间'),
fn('CURDATE', 'MySQL - 当前日期'),
fn('CURTIME', 'MySQL - 当前时间'),
fn('DATE_FORMAT', 'MySQL - 日期格式化'),
fn('DATE_ADD', 'MySQL - 日期加法'),
fn('DATE_SUB', 'MySQL - 日期减法'),
fn('DATEDIFF', 'MySQL - 日期差'),
fn('TIMESTAMPDIFF', 'MySQL - 时间戳差'),
fn('STR_TO_DATE', 'MySQL - 字符串转日期'),
fn('UNIX_TIMESTAMP', 'MySQL - Unix 时间戳'),
fn('IF', 'MySQL - 条件判断'),
fn('IFNULL', 'MySQL - NULL 替换'),
fn('JSON_EXTRACT', 'MySQL - JSON 提取'),
fn('JSON_UNQUOTE', 'MySQL - JSON 去引号'),
fn('JSON_SET', 'MySQL - JSON 设置'),
fn('MD5', 'MySQL - MD5 哈希'),
fn('SHA1', 'MySQL - SHA1 哈希'),
fn('SHA2', 'MySQL - SHA2 哈希'),
fn('UUID', 'MySQL - 生成 UUID'),
fn('DATABASE', 'MySQL - 当前数据库'),
fn('VERSION', 'MySQL - 版本'),
fn('LAST_INSERT_ID', 'MySQL - 最后插入 ID'),
];
const PG_FUNCTIONS = [
fn('STRING_AGG', 'PostgreSQL - 字符串聚合'),
fn('ARRAY_AGG', 'PostgreSQL - 数组聚合'),
fn('BOOL_AND', 'PostgreSQL - 布尔与聚合'),
fn('BOOL_OR', 'PostgreSQL - 布尔或聚合'),
fn('POSITION', 'PostgreSQL - 查找位置'),
fn('EXTRACT', 'PostgreSQL - 日期字段提取'),
fn('DATE_TRUNC', 'PostgreSQL - 日期截断'),
fn('NOW', 'PostgreSQL - 当前时间'),
fn('TO_CHAR', 'PostgreSQL - 格式化为文本'),
fn('TO_DATE', 'PostgreSQL - 文本转日期'),
fn('TO_TIMESTAMP', 'PostgreSQL - 文本转时间戳'),
fn('AGE', 'PostgreSQL - 时间差'),
fn('RANDOM', 'PostgreSQL - 随机数'),
fn('CURRENT_DATABASE', 'PostgreSQL - 当前数据库'),
fn('JSONB_EXTRACT_PATH', 'PostgreSQL - JSONB 路径提取'),
];
const ORACLE_FUNCTIONS = [
fn('LISTAGG', 'Oracle - 字符串聚合'),
fn('NVL', 'Oracle - NULL 替换'),
fn('NVL2', 'Oracle - NULL 分支'),
fn('DECODE', 'Oracle - 条件映射'),
fn('TO_DATE', 'Oracle - 文本转日期'),
fn('TO_TIMESTAMP', 'Oracle - 文本转时间戳'),
fn('TO_CHAR', 'Oracle - 格式化为文本'),
fn('TO_NUMBER', 'Oracle - 转数字'),
fn('TRUNC', 'Oracle - 截断日期或数字'),
fn('ADD_MONTHS', 'Oracle - 增加月份'),
fn('MONTHS_BETWEEN', 'Oracle - 月份差'),
fn('LAST_DAY', 'Oracle - 月末日期'),
fn('SYSDATE', 'Oracle - 数据库当前时间'),
fn('SYSTIMESTAMP', 'Oracle - 当前时间戳'),
fn('INSTR', 'Oracle - 查找位置'),
fn('REGEXP_LIKE', 'Oracle - 正则匹配'),
fn('REGEXP_REPLACE', 'Oracle - 正则替换'),
fn('USER', 'Oracle - 当前用户'),
];
const SQLSERVER_FUNCTIONS = [
fn('GETDATE', 'SQL Server - 当前日期时间'),
fn('SYSDATETIME', 'SQL Server - 高精度当前时间'),
fn('DATEADD', 'SQL Server - 日期加法'),
fn('DATEDIFF', 'SQL Server - 日期差'),
fn('FORMAT', 'SQL Server - 格式化'),
fn('ISNULL', 'SQL Server - NULL 替换'),
fn('IIF', 'SQL Server - 条件判断'),
fn('NEWID', 'SQL Server - 生成 GUID'),
fn('STRING_AGG', 'SQL Server - 字符串聚合'),
fn('LEFT', 'SQL Server - 从左截取'),
fn('RIGHT', 'SQL Server - 从右截取'),
fn('LEN', 'SQL Server - 字符长度'),
fn('CHARINDEX', 'SQL Server - 查找位置'),
fn('TRY_CAST', 'SQL Server - 尝试转换'),
fn('TRY_CONVERT', 'SQL Server - 尝试转换'),
fn('DB_NAME', 'SQL Server - 当前数据库'),
];
const SQLITE_FUNCTIONS = [
fn('DATE', 'SQLite - 日期'),
fn('TIME', 'SQLite - 时间'),
fn('DATETIME', 'SQLite - 日期时间'),
fn('JULIANDAY', 'SQLite - 儒略日'),
fn('STRFTIME', 'SQLite - 日期格式化'),
fn('IFNULL', 'SQLite - NULL 替换'),
fn('RANDOM', 'SQLite - 随机数'),
fn('PRINTF', 'SQLite - 格式化'),
fn('HEX', 'SQLite - 十六进制'),
fn('QUOTE', 'SQLite - SQL 字面量'),
fn('JSON_EXTRACT', 'SQLite - JSON 提取'),
];
const DUCKDB_FUNCTIONS = [
fn('LIST', 'DuckDB - 列表聚合'),
fn('STRUCT_PACK', 'DuckDB - 构造结构体'),
fn('UNNEST', 'DuckDB - 展开列表'),
fn('STRFTIME', 'DuckDB - 日期格式化'),
fn('EPOCH', 'DuckDB - 时间戳秒数'),
fn('RANDOM', 'DuckDB - 随机数'),
fn('UUID', 'DuckDB - 生成 UUID'),
];
const CLICKHOUSE_FUNCTIONS = [
fn('now', 'ClickHouse - 当前时间'),
fn('today', 'ClickHouse - 当前日期'),
fn('toDate', 'ClickHouse - 转日期'),
fn('toDateTime', 'ClickHouse - 转日期时间'),
fn('formatDateTime', 'ClickHouse - 日期格式化'),
fn('groupArray', 'ClickHouse - 数组聚合'),
fn('groupUniqArray', 'ClickHouse - 去重数组聚合'),
fn('uniq', 'ClickHouse - 近似去重'),
fn('uniqExact', 'ClickHouse - 精确去重'),
fn('quantile', 'ClickHouse - 分位数'),
fn('JSONExtractString', 'ClickHouse - JSON 字符串提取'),
fn('toString', 'ClickHouse - 转字符串'),
fn('toInt64', 'ClickHouse - 转 Int64'),
];
const TDENGINE_FUNCTIONS = [
fn('NOW', 'TDengine - 当前时间'),
fn('TODAY', 'TDengine - 当前日期'),
fn('TIMEDIFF', 'TDengine - 时间差'),
fn('ELAPSED', 'TDengine - 经过时间'),
fn('SPREAD', 'TDengine - 最大最小差'),
fn('TWA', 'TDengine - 时间加权平均'),
fn('LEASTSQUARES', 'TDengine - 最小二乘'),
fn('APERCENTILE', 'TDengine - 近似百分位'),
fn('FIRST', 'TDengine - 首值'),
fn('LAST', 'TDengine - 末值'),
fn('LAST_ROW', 'TDengine - 最后一行'),
fn('INTERP', 'TDengine - 插值'),
fn('RATE', 'TDengine - 变化率'),
fn('IRATE', 'TDengine - 瞬时变化率'),
];
const mergeFunctions = (items: SqlFunctionCompletion[]): SqlFunctionCompletion[] => {
const seen = new Set<string>();
const result: SqlFunctionCompletion[] = [];
for (const item of items) {
const key = item.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
};
export const resolveSqlFunctions = (dbType: string): SqlFunctionCompletion[] => {
const dialect = resolveSqlDialect(dbType);
if (isMysqlFamilyDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...MYSQL_FUNCTIONS]);
if (isPgLikeDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...PG_FUNCTIONS]);
if (isOracleLikeDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...ORACLE_FUNCTIONS]);
if (dialect === 'sqlserver') return mergeFunctions([...COMMON_FUNCTIONS, ...SQLSERVER_FUNCTIONS]);
if (dialect === 'sqlite') return mergeFunctions([...COMMON_FUNCTIONS, ...SQLITE_FUNCTIONS]);
if (dialect === 'duckdb') return mergeFunctions([...COMMON_FUNCTIONS, ...DUCKDB_FUNCTIONS]);
if (dialect === 'clickhouse') return mergeFunctions([...COMMON_FUNCTIONS, ...CLICKHOUSE_FUNCTIONS]);
if (dialect === 'tdengine') return mergeFunctions([...COMMON_FUNCTIONS, ...TDENGINE_FUNCTIONS]);
return COMMON_FUNCTIONS;
};