mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
🐛 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:
24
docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md
Normal file
24
docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# SQL 方言适配需求进度追踪
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
|
||||||
|
- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 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 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
## 风险与后续
|
||||||
|
|
||||||
|
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。
|
||||||
|
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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('`');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
};
|
||||||
|
|||||||
58
frontend/src/utils/sqlDialect.test.ts
Normal file
58
frontend/src/utils/sqlDialect.test.ts
Normal 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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
715
frontend/src/utils/sqlDialect.ts
Normal file
715
frontend/src/utils/sqlDialect.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user