diff --git a/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md b/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md new file mode 100644 index 0000000..404541b --- /dev/null +++ b/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md @@ -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 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。 diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 8b952bc..3cc8fcd 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -13,6 +13,7 @@ import { convertMongoShellToJsonCommand } from '../utils/mongodb'; import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; const SQL_KEYWORDS = [ 'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', @@ -521,6 +522,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc startColumn: word.startColumn, endColumn: word.endColumn, }; + const activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId); + const activeDialect = resolveSqlDialect( + String(activeConnection?.config?.type || ''), + String(activeConnection?.config?.driver || ''), + ); + const dialectKeywords = resolveSqlKeywords(activeDialect); + const dialectFunctions = resolveSqlFunctions(activeDialect); const stripQuotes = (ident: string) => { let raw = (ident || '').trim(); @@ -776,7 +784,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim()); const shouldBoostKeywords = !expectsTableName && wordPrefix.length > 0 - && SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); + && dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); const sortGroups = shouldBoostKeywords ? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } : expectsTableName @@ -878,7 +886,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc })); // 关键字提示 - const keywordSuggestions = SQL_KEYWORDS + const keywordSuggestions = dialectKeywords .filter((k) => startsWithPrefix(k)) .map(k => ({ label: k, @@ -889,7 +897,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc })); // 内置函数提示 - const funcSuggestions = SQL_FUNCTIONS + const funcSuggestions = dialectFunctions .filter((f) => startsWithPrefix(f.name)) .map(f => ({ label: f.name, diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 2b29caa..a9194bb 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -9,9 +9,19 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg import { useStore } from '../store'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils'; -import { buildAlterTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql'; +import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; +import { + isMysqlFamilyDialect as isMysqlFamilySqlDialect, + isOracleLikeDialect as isOracleLikeSqlDialect, + isPgLikeDialect as isPgLikeSqlDialect, + isSqlServerDialect as isSqlServerSqlDialect, + quoteSqlIdentifierPart, + quoteSqlIdentifierPath, + resolveColumnTypeOptions, + resolveSqlDialect, +} from '../utils/sqlDialect'; interface EditableColumn extends ColumnDefinition { _key: string; @@ -540,6 +550,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { // Initial Columns Definition useEffect(() => { + const columnTypeOptions = resolveColumnTypeOptions(getDbType()); const initialCols = [ { title: '名', @@ -556,7 +567,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { key: 'type', width: 150, render: (text: string, record: EditableColumn) => readOnly ? text : ( - handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" /> + handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" /> ) }, { @@ -636,7 +647,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { }]) ]; setTableColumns(initialCols); - }, [readOnly]); // Re-create if readOnly changes + }, [connections, openCommentEditor, readOnly, tab.connectionId]); // Re-create when datasource dialect or readonly state changes const flushResizeGhost = useCallback(() => { resizeRafRef.current = null; @@ -847,16 +858,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const getDbType = (): string => { const conn = connections.find(c => c.id === tab.connectionId); - const type = normalizeDbType(String(conn?.config?.type || '')); - if (!type) return ''; - - if (type === 'custom') { - return inferDialectFromCustomDriver(String(conn?.config?.driver || '')); - } - - if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; - if (type === 'dameng') return 'dm'; - return type; + const rawType = String(conn?.config?.type || '').trim(); + if (!rawType) return ''; + return resolveSqlDialect(rawType, String(conn?.config?.driver || '')); }; const generateTriggerTemplate = (): string => { @@ -865,6 +869,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { switch (dbType) { case 'mysql': + case 'mariadb': + case 'diros': return `CREATE TRIGGER trigger_name BEFORE INSERT ON \`${tblName}\` FOR EACH ROW @@ -897,6 +903,7 @@ BEGIN -- 触发器逻辑 END;`; case 'oracle': + case 'dameng': case 'dm': return `CREATE OR REPLACE TRIGGER trigger_name BEFORE INSERT ON "${tblName}" @@ -922,6 +929,8 @@ END;`; switch (dbType) { case 'mysql': + case 'mariadb': + case 'diros': return `DROP TRIGGER IF EXISTS \`${triggerName}\``; case 'postgres': case 'kingbase': @@ -931,6 +940,7 @@ END;`; case 'sqlserver': return `DROP TRIGGER IF EXISTS [${triggerName}]`; case 'oracle': + case 'dameng': case 'dm': return `DROP TRIGGER "${triggerName}"`; case 'sqlite': @@ -1334,36 +1344,20 @@ ${selectedTrigger.statement}`; }; }; - const isPgLikeDialect = (dbType: string): boolean => - dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase'; - const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm'; - const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver'; - const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql'; + const isPgLikeDialect = (dbType: string): boolean => isPgLikeSqlDialect(dbType); + const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType); + const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType); + const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType); const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb'; const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine'; const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite'; const quoteIdentifierPartByDialect = (part: string, dbType: string): string => { - const ident = stripIdentifierQuotes(part); - if (!ident) return ''; - if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') { - return `\`${escapeBacktickIdentifier(ident)}\``; - } - if (isSqlServerDialect(dbType)) { - return `[${escapeBracketIdentifier(ident)}]`; - } - return `"${escapeDoubleQuoteIdentifier(ident)}"`; + return quoteSqlIdentifierPart(dbType, part); }; const quoteIdentifierPathByDialect = (path: string, dbType: string): string => { - const raw = String(path || '').trim(); - if (!raw) return ''; - const parts = raw - .split('.') - .map(part => stripIdentifierQuotes(part)) - .filter(Boolean); - if (parts.length === 0) return ''; - return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.'); + return quoteSqlIdentifierPath(dbType, path); }; const resolveTableInfo = () => { @@ -1481,19 +1475,13 @@ ${selectedTrigger.statement}`; }; const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { - const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``; - const colDefs = targetColumns.map(curr => { - let extra = curr.extra || ""; - if (curr.isAutoIncrement && !extra.toLowerCase().includes('auto_increment')) { - extra += " AUTO_INCREMENT"; - } - return `\`${escapeBacktickIdentifier(curr.name)}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${escapeSqlString(String(curr.default))}'` : ''} ${extra} COMMENT '${escapeSqlString(curr.comment || '')}'`; + return buildCreateTablePreviewSql({ + dbType: getDbType(), + tableName: targetTableName, + columns: targetColumns, + charset: targetCharset, + collation: targetCollation, }); - const pks = targetColumns.filter(c => c.key === 'PRI').map(c => `\`${escapeBacktickIdentifier(c.name)}\``); - if (pks.length > 0) { - colDefs.push(`PRIMARY KEY (${pks.join(', ')})`); - } - return `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${targetCharset} COLLATE=${targetCollation};`; }; const openCopySelectedColumnsModal = () => { diff --git a/frontend/src/components/dataGridCopyInsert.test.ts b/frontend/src/components/dataGridCopyInsert.test.ts index 00e33c8..0c5e44b 100644 --- a/frontend/src/components/dataGridCopyInsert.test.ts +++ b/frontend/src/components/dataGridCopyInsert.test.ts @@ -141,6 +141,33 @@ describe('buildCopyInsertSQL', () => { }); }); + it('uses Oracle date constructors when all-column DELETE matching includes DATE values', () => { + const result = buildCopyDeleteSQL({ + dbType: 'oracle', + tableName: 'LZJ.RIJIE_TABLE', + orderedCols: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'], + allTableColumns: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'], + record: { + NAME: '张三', + CREATED_AT: '2026-04-26T08:30:00+08:00', + STATUS: 'DONE', + MEMO: null, + }, + columnTypesByLowerName: { + name: 'NVARCHAR2', + created_at: 'DATE', + status: 'VARCHAR2', + memo: 'VARCHAR2', + }, + }); + + expect(result).toEqual({ + ok: true, + whereStrategy: 'all-columns', + sql: `DELETE FROM "LZJ"."RIJIE_TABLE" WHERE ("NAME" = '张三' AND "CREATED_AT" = TO_DATE('2026-04-26 08:30:00', 'YYYY-MM-DD HH24:MI:SS') AND "STATUS" = 'DONE' AND "MEMO" IS NULL);`, + }); + }); + it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => { const result = buildCopyDeleteSQL({ dbType: 'mysql', diff --git a/frontend/src/components/dataGridCopyInsert.ts b/frontend/src/components/dataGridCopyInsert.ts index 8b8c039..e1a7178 100644 --- a/frontend/src/components/dataGridCopyInsert.ts +++ b/frontend/src/components/dataGridCopyInsert.ts @@ -1,5 +1,6 @@ import type { IndexDefinition } from '../types'; import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; +import { isOracleLikeDialect } from '../utils/sqlDialect'; type BuildCopyInsertSQLParams = { dbType: string; @@ -164,10 +165,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => { return String(value); }; -const formatCopySqlLiteral = (value: any, columnType?: string): string => { +const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => { + if (!isTemporalColumnType(columnType)) { + return null; + } + const normalized = toNormalizedLiteralText(value, columnType); + const escaped = escapeLiteral(normalized); + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return `TO_DATE('${escaped}', 'YYYY-MM-DD')`; + } + if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) { + const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2'); + return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`; + } + const rawType = String(columnType || '').toLowerCase(); + if (rawType.includes('timestamp')) { + return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`; + } + return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`; +}; + +const formatCopySqlLiteral = (value: any, columnType?: string, dbType = ''): string => { if (value === null || value === undefined) { return 'NULL'; } + if (isOracleLikeDialect(dbType)) { + const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType); + if (oracleTemporalLiteral) { + return oracleTemporalLiteral; + } + } return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`; }; @@ -208,7 +235,7 @@ const buildWhereClauseForColumns = ({ predicates.push(`${quotedColumn} IS NULL`); continue; } - predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`); + predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`); } if (predicates.length === 0) { return null; @@ -283,7 +310,7 @@ export const buildCopyInsertSQL = ({ const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col)); const values = orderedCols.map((col) => { const { value } = getRecordValue(record, col); - return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col)); + return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType); }); return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`; @@ -341,7 +368,7 @@ const buildCopyMutationSQL = ( const assignments = normalizedOrderedCols.map((columnName) => { const { value } = getRecordValue(record, columnName); - return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`; + return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`; }); return { diff --git a/frontend/src/components/tableDesignerSchemaSql.test.ts b/frontend/src/components/tableDesignerSchemaSql.test.ts index c29c913..4c7ad91 100644 --- a/frontend/src/components/tableDesignerSchemaSql.test.ts +++ b/frontend/src/components/tableDesignerSchemaSql.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildCreateTablePreviewSql, buildAlterTablePreviewSql, hasAlterTableDraftChanges, type BuildAlterTablePreviewInput, @@ -76,4 +77,140 @@ describe('tableDesignerSchemaSql', () => { expect(sql).toContain('FIRST'); expect(sql).not.toContain('MODIFY COLUMN `display_name`'); }); + + it('builds oracle alter preview with oracle rename and modify syntax', () => { + const sql = buildAlterTablePreviewSql(buildInput({ + dbType: 'oracle', + tableName: 'HR.EMPLOYEES', + originalColumns: [ + baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(64)', nullable: 'YES', comment: '旧名称' }), + ], + columns: [ + baseColumn({ + _key: 'name', + name: 'DISPLAY_NAME', + type: 'VARCHAR2(128)', + nullable: 'NO', + default: 'guest', + comment: '显示名', + }), + ], + })); + + expect(sql).toContain('ALTER TABLE "HR"."EMPLOYEES"\nRENAME COLUMN "NAME" TO "DISPLAY_NAME";'); + expect(sql).toContain(`ALTER TABLE "HR"."EMPLOYEES"\nMODIFY ("DISPLAY_NAME" VARCHAR2(128) DEFAULT 'guest' NOT NULL);`); + expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."DISPLAY_NAME" IS '显示名';`); + expect(sql).not.toContain('`'); + expect(sql).not.toContain('CHANGE COLUMN'); + expect(sql).not.toContain('AUTO_INCREMENT'); + }); + + it('builds sqlserver alter preview with sp_rename and alter column syntax', () => { + const sql = buildAlterTablePreviewSql(buildInput({ + dbType: 'sqlserver', + tableName: 'dbo.Users', + originalColumns: [ + baseColumn({ _key: 'name', name: 'name', type: 'nvarchar(64)', nullable: 'YES' }), + ], + columns: [ + baseColumn({ _key: 'name', name: 'display_name', type: 'nvarchar(128)', nullable: 'NO' }), + ], + })); + + expect(sql).toContain(`EXEC sp_rename 'dbo.Users.name', 'display_name', 'COLUMN';`); + expect(sql).toContain('ALTER TABLE [dbo].[Users]\nALTER COLUMN [display_name] nvarchar(128) NOT NULL;'); + expect(sql).not.toContain('CHANGE COLUMN'); + expect(sql).not.toContain('MODIFY COLUMN'); + expect(sql).not.toContain('`'); + }); + + it('keeps sqlite alter preview limited to sqlite-supported operations', () => { + const sql = buildAlterTablePreviewSql(buildInput({ + dbType: 'sqlite', + tableName: 'users', + originalColumns: [ + baseColumn({ _key: 'name', name: 'name', type: 'TEXT', nullable: 'YES' }), + ], + columns: [ + baseColumn({ _key: 'name', name: 'display_name', type: 'INTEGER', nullable: 'NO' }), + ], + })); + + expect(sql).toContain('ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'); + expect(sql).toContain('-- SQLite 不支持直接修改字段属性'); + expect(sql).not.toContain('CHANGE COLUMN'); + expect(sql).not.toContain('MODIFY COLUMN'); + expect(sql).not.toContain('AFTER'); + }); + + it('builds duckdb alter preview without mysql-only syntax', () => { + const sql = buildAlterTablePreviewSql(buildInput({ + dbType: 'duckdb', + tableName: 'main.users', + originalColumns: [ + baseColumn({ _key: 'score', name: 'score', type: 'INTEGER', nullable: 'YES', default: '0' }), + ], + columns: [ + baseColumn({ _key: 'score', name: 'score', type: 'BIGINT', nullable: 'NO', default: '1' }), + ], + })); + + expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DATA TYPE BIGINT;'); + expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DEFAULT 1;'); + expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET NOT NULL;'); + expect(sql).not.toContain('CHANGE COLUMN'); + expect(sql).not.toContain('MODIFY COLUMN'); + }); + + it('uses native limited alter syntax for clickhouse and tdengine instead of mysql syntax', () => { + const clickhouseSql = buildAlterTablePreviewSql(buildInput({ + dbType: 'clickhouse', + tableName: 'events', + originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'String', nullable: 'YES' })], + columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'String', nullable: 'YES' })], + })); + const tdengineSql = buildAlterTablePreviewSql(buildInput({ + dbType: 'tdengine', + tableName: 'meters', + originalColumns: [baseColumn({ _key: 'value', name: 'value', type: 'FLOAT', nullable: 'YES' })], + columns: [baseColumn({ _key: 'value', name: 'value', type: 'DOUBLE', nullable: 'YES' })], + })); + + expect(clickhouseSql).toContain('ALTER TABLE `events`\nRENAME COLUMN `name` TO `display_name`;'); + expect(tdengineSql).toContain('ALTER TABLE `meters`\nMODIFY COLUMN `value` DOUBLE;'); + expect(clickhouseSql).not.toContain('CHANGE COLUMN'); + expect(tdengineSql).not.toContain('CHANGE COLUMN'); + expect(clickhouseSql).not.toContain('AFTER'); + expect(tdengineSql).not.toContain('AFTER'); + }); + + it('treats mariadb doris and sphinx as mysql-family only where mysql syntax is intended', () => { + for (const dbType of ['mariadb', 'diros', 'sphinx']) { + const sql = buildAlterTablePreviewSql(buildInput({ dbType })); + expect(sql).toContain('ALTER TABLE `users`'); + expect(sql).toContain('ADD COLUMN `age` int NULL'); + } + }); + + it('builds oracle create table preview without mysql table options', () => { + const sql = buildCreateTablePreviewSql({ + dbType: 'oracle', + tableName: 'HR.EMPLOYEES', + charset: 'utf8mb4', + collation: 'utf8mb4_unicode_ci', + columns: [ + baseColumn({ _key: 'id', name: 'ID', type: 'NUMBER(10)', nullable: 'NO', key: 'PRI', isAutoIncrement: true }), + baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(255)', nullable: 'YES', comment: '姓名' }), + ], + }); + + expect(sql).toContain('CREATE TABLE "HR"."EMPLOYEES"'); + expect(sql).toContain('"ID" NUMBER(10) GENERATED BY DEFAULT AS IDENTITY NOT NULL'); + expect(sql).toContain('PRIMARY KEY ("ID")'); + expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."NAME" IS '姓名';`); + expect(sql).not.toContain('ENGINE=InnoDB'); + expect(sql).not.toContain('DEFAULT CHARSET'); + expect(sql).not.toContain('AUTO_INCREMENT'); + expect(sql).not.toContain('`'); + }); }); diff --git a/frontend/src/components/tableDesignerSchemaSql.ts b/frontend/src/components/tableDesignerSchemaSql.ts index 7851dab..4f67437 100644 --- a/frontend/src/components/tableDesignerSchemaSql.ts +++ b/frontend/src/components/tableDesignerSchemaSql.ts @@ -1,3 +1,16 @@ +import { + isBacktickIdentifierDialect, + isMysqlFamilyDialect, + isOracleLikeDialect, + isPgLikeDialect, + isSqlServerDialect, + quoteSqlIdentifierPart, + quoteSqlIdentifierPath, + resolveSqlDialect, + unquoteSqlIdentifierPart, + unquoteSqlIdentifierPath, +} from '../utils/sqlDialect'; + export interface EditableColumnSnapshot { _key: string; name: string; @@ -17,21 +30,17 @@ export interface BuildAlterTablePreviewInput { columns: EditableColumnSnapshot[]; } -const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); -const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``'); -const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""'); +export interface BuildCreateTablePreviewInput { + dbType: string; + tableName: string; + columns: EditableColumnSnapshot[]; + charset?: string; + collation?: string; +} -const stripIdentifierQuotes = (part: string): string => { - const text = String(part || '').trim(); - if (!text) return ''; - if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) { - return text.slice(1, -1).trim(); - } - if (text.startsWith('[') && text.endsWith(']')) { - return text.slice(1, -1).replace(/]]/g, ']').trim(); - } - return text; -}; +const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); + +const stripIdentifierQuotes = unquoteSqlIdentifierPart; const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { const raw = String(qualifiedName || '').trim(); @@ -44,117 +53,158 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object }; }; -const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql'; -const isPgLikeDialect = (dbType: string): boolean => - dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase'; +const quoteIdentifierPart = (part: string, dbType: string): string => quoteSqlIdentifierPart(dbType, part); -const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident); +const quoteIdentifierPath = (path: string, dbType: string): string => quoteSqlIdentifierPath(dbType, path); -const quoteIdentifierPart = (part: string, dbType: string): string => { - const ident = stripIdentifierQuotes(part); - if (!ident) return ''; - if (isMysqlLikeDialect(dbType)) { - return `\`${escapeBacktickIdentifier(ident)}\``; - } - if (isPgLikeDialect(dbType)) { - if (!needsPgLikeQuote(ident)) { - return ident; - } - return `"${escapeDoubleQuoteIdentifier(ident)}"`; - } - return ident; +const normalizeDefaultText = (value: unknown): string => String(value ?? '').trim(); + +const isKnownDefaultExpression = (trimmed: string): boolean => { + if (!trimmed) return false; + if (/^N?'.*'$/i.test(trimmed)) return true; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return true; + if (/^(true|false|null)$/i.test(trimmed)) return true; + if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) return true; + if (/^(now|uuid|newid|sysdatetime)\s*\(\s*\)$/i.test(trimmed)) return true; + if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return true; + return false; }; -const quoteIdentifierPath = (path: string, dbType: string): string => - String(path || '') - .trim() - .split('.') - .map((part) => stripIdentifierQuotes(part)) - .filter(Boolean) - .map((part) => quoteIdentifierPart(part, dbType)) - .join('.'); - -const formatPgLikeDefault = (value: string): string => { - const trimmed = String(value || '').trim(); +const formatDefaultExpression = (value: unknown, dbType: string): string => { + const trimmed = normalizeDefaultText(value); if (!trimmed) return ''; - if (/^'.*'$/.test(trimmed)) return trimmed; - if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed; - if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase(); - if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase(); - if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed; - return `'${escapeSqlString(trimmed)}'`; + if (isKnownDefaultExpression(trimmed)) { + if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase(); + if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) { + return trimmed.toUpperCase(); + } + return trimmed; + } + const prefix = isSqlServerDialect(dbType) ? 'N' : ''; + return `${prefix}'${escapeSqlString(trimmed)}'`; }; -const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => { - let extra = String(column.extra || ''); +const buildDefaultSql = (value: unknown, dbType: string): string => { + const defaultValue = normalizeDefaultText(value); + if (!defaultValue) return ''; + return `DEFAULT ${formatDefaultExpression(defaultValue, dbType)}`; +}; + +const definitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => ( + curr.type !== orig.type || + curr.nullable !== orig.nullable || + normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) || + (curr.comment || '') !== (orig.comment || '') || + Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement) +); + +const physicalDefinitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => ( + curr.type !== orig.type || + curr.nullable !== orig.nullable || + normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) || + Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement) +); + +const buildMySqlColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => { + let extra = String(column.extra || '').trim(); if (column.isAutoIncrement) { if (!extra.toLowerCase().includes('auto_increment')) { - extra += ' AUTO_INCREMENT'; + extra = `${extra} AUTO_INCREMENT`.trim(); } } else { extra = extra.replace(/auto_increment/gi, '').trim(); } - const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : ''; - return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim(); + const defaultSql = buildDefaultSql(column.default, dbType); + return [ + quoteIdentifierPart(column.name, dbType), + String(column.type || '').trim(), + column.nullable === 'NO' ? 'NOT NULL' : 'NULL', + defaultSql, + extra, + `COMMENT '${escapeSqlString(column.comment || '')}'`, + ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); }; -const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot): string => { - const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()]; - const defaultValue = String(column.default || '').trim(); - if (defaultValue) { - parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`); +const buildStandardColumnDefinition = ( + column: EditableColumnSnapshot, + dbType: string, + options: { includeNull?: boolean; includeIdentity?: boolean } = {}, +): string => { + const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()]; + if (options.includeIdentity && column.isAutoIncrement) { + if (isSqlServerDialect(dbType)) { + parts.push('IDENTITY(1,1)'); + } else if (isOracleLikeDialect(dbType)) { + parts.push('GENERATED BY DEFAULT AS IDENTITY'); + } } + const defaultSql = buildDefaultSql(column.default, dbType); + if (defaultSql) parts.push(defaultSql); if (column.nullable === 'NO') { parts.push('NOT NULL'); + } else if (options.includeNull) { + parts.push('NULL'); } + return parts.filter(Boolean).join(' ').trim(); +}; + +const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => { + const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()]; + const defaultSql = buildDefaultSql(column.default, dbType); + if (defaultSql) parts.push(defaultSql); + if (column.nullable === 'NO') parts.push('NOT NULL'); return parts.join(' ').trim(); }; -const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => { - const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`; +const buildColumnCommentSql = (tableRef: string, columnName: string, comment: string, dbType: string): string => { + const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, dbType)}`; const trimmed = String(comment || '').trim(); - if (!trimmed) { + if (!trimmed && isPgLikeDialect(dbType)) { return `COMMENT ON COLUMN ${columnRef} IS NULL;`; } return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`; }; -const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { - const tableName = quoteIdentifierPath(input.tableName, 'mysql'); +const buildSqlServerColumnCommentSql = ( + tableName: string, + columnName: string, + comment: string, +): string => { + const { schemaName, objectName } = splitQualifiedName(tableName); + const schema = escapeSqlString(schemaName || 'dbo'); + const table = escapeSqlString(objectName || tableName); + const column = escapeSqlString(columnName); + const value = escapeSqlString(comment || ''); + return `IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.tables t ON ep.major_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id JOIN sys.columns c ON ep.major_id = c.object_id AND ep.minor_id = c.column_id WHERE ep.name = N'MS_Description' AND s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}') BEGIN EXEC sp_updateextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END ELSE BEGIN EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END;`; +}; + +const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => { + const tableName = quoteIdentifierPath(input.tableName, dbType); const alters: string[] = []; input.originalColumns.forEach((orig) => { if (!input.columns.find((col) => col._key === orig._key)) { - alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`); + alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, dbType)}`); } }); input.columns.forEach((curr, index) => { const orig = input.originalColumns.find((col) => col._key === curr._key); const prevCol = index > 0 ? input.columns[index - 1] : null; - const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST'; - const colDef = buildMySqlColumnDefinition(curr); + const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, dbType)}` : 'FIRST'; + const colDef = buildMySqlColumnDefinition(curr, dbType); if (!orig) { alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim()); return; } - const definitionChanged = - curr.type !== orig.type || - curr.nullable !== orig.nullable || - curr.default !== orig.default || - (curr.comment || '') !== (orig.comment || '') || - Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement); - if (curr.name !== orig.name) { - alters.push( - `CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(), - ); + alters.push(`CHANGE COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${colDef} ${positionSql}`.trim()); return; } - if (definitionChanged) { + if (definitionChanged(curr, orig)) { alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim()); } }); @@ -163,74 +213,65 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string = const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key); const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); if (keysChanged) { - if (origPKKeys.length > 0) { - alters.push('DROP PRIMARY KEY'); - } + if (origPKKeys.length > 0) alters.push('DROP PRIMARY KEY'); if (newPKKeys.length > 0) { const pkNames = input.columns .filter((col) => col.key === 'PRI') - .map((col) => quoteIdentifierPart(col.name, 'mysql')) + .map((col) => quoteIdentifierPart(col.name, dbType)) .join(', '); alters.push(`ADD PRIMARY KEY (${pkNames})`); } } - if (alters.length === 0) { - return ''; - } - return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`; + return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`; }; -const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { +const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => { const tableParts = splitQualifiedName(input.tableName); const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName); - const tableRef = quoteIdentifierPath(input.tableName, 'postgres'); + const tableRef = quoteIdentifierPath(input.tableName, dbType); const statements: string[] = []; input.originalColumns.forEach((orig) => { if (!input.columns.find((col) => col._key === orig._key)) { - statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, 'postgres')};`); + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`); } }); input.columns.forEach((curr) => { const orig = input.originalColumns.find((col) => col._key === curr._key); if (!orig) { - statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`); - if (String(curr.comment || '').trim()) { - statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || '')); - } + statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr, dbType)};`); + if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType)); return; } let currentName = orig.name; if (curr.name !== orig.name) { - statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, 'postgres')} TO ${quoteIdentifierPart(curr.name, 'postgres')};`); + statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`); currentName = curr.name; } if (curr.type !== orig.type) { - statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`); + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} TYPE ${curr.type};`); } - const currDefault = String(curr.default || '').trim(); - const origDefault = String(orig.default || '').trim(); + const currDefault = normalizeDefaultText(curr.default); + const origDefault = normalizeDefaultText(orig.default); if (currDefault !== origDefault) { if (currDefault) { - statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`); + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`); } else { - statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`); + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`); } } if (curr.nullable !== orig.nullable) { - statements.push( - `ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`, - ); + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`); } if ((curr.comment || '') !== (orig.comment || '')) { - statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || '')); + statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType)); } }); @@ -239,12 +280,12 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); if (keysChanged) { if (origPKKeys.length > 0) { - statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, 'postgres')};`); + statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, dbType)};`); } if (newPKKeys.length > 0) { const pkNames = input.columns .filter((col) => col.key === 'PRI') - .map((col) => quoteIdentifierPart(col.name, 'postgres')) + .map((col) => quoteIdentifierPart(col.name, dbType)) .join(', '); statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`); } @@ -253,13 +294,322 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string return statements.join('\n'); }; -export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => { - const dbType = String(input.dbType || '').trim().toLowerCase(); - if (isPgLikeDialect(dbType)) { - return buildPgLikeAlterPreviewSql({ ...input, dbType }); +const buildOracleLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => { + const tableRef = quoteIdentifierPath(input.tableName, dbType); + const statements: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`); + } + }); + + input.columns.forEach((curr) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + if (!orig) { + statements.push(`ALTER TABLE ${tableRef}\nADD (${buildStandardColumnDefinition(curr, dbType, { includeIdentity: true })});`); + if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType)); + return; + } + + let currentName = orig.name; + if (curr.name !== orig.name) { + statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`); + currentName = curr.name; + } + + if (physicalDefinitionChanged(curr, orig)) { + statements.push(`ALTER TABLE ${tableRef}\nMODIFY (${buildStandardColumnDefinition({ ...curr, name: currentName }, dbType, { includeIdentity: true })});`); + } + + if ((curr.comment || '') !== (orig.comment || '')) { + statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType)); + } + }); + + const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key); + const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key); + const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); + if (keysChanged) { + if (origPKKeys.length > 0) statements.push(`ALTER TABLE ${tableRef}\nDROP PRIMARY KEY;`); + if (newPKKeys.length > 0) { + const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', '); + statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`); + } } - return buildMySqlAlterPreviewSql({ ...input, dbType }); + + return statements.join('\n'); +}; + +const buildSqlServerDefaultDropBatch = (tableName: string, columnName: string): string => { + const { schemaName, objectName } = splitQualifiedName(tableName); + const schema = escapeSqlString(schemaName || 'dbo'); + const table = escapeSqlString(objectName || tableName); + const column = escapeSqlString(columnName); + const tableRef = quoteIdentifierPath(`${schemaName || 'dbo'}.${objectName || tableName}`, 'sqlserver'); + return `DECLARE @gonavi_df nvarchar(128); SELECT @gonavi_df = dc.name FROM sys.default_constraints dc JOIN sys.columns c ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}'; IF @gonavi_df IS NOT NULL EXEC(N'ALTER TABLE ${tableRef} DROP CONSTRAINT ' + QUOTENAME(@gonavi_df));`; +}; + +const buildSqlServerAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { + const dbType = 'sqlserver'; + const tableRef = quoteIdentifierPath(input.tableName, dbType); + const statements: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`); + } + }); + + input.columns.forEach((curr) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + if (!orig) { + statements.push(`ALTER TABLE ${tableRef}\nADD ${buildStandardColumnDefinition(curr, dbType, { includeNull: true, includeIdentity: true })};`); + if (String(curr.comment || '').trim()) statements.push(buildSqlServerColumnCommentSql(input.tableName, curr.name, curr.comment || '')); + return; + } + + let currentName = orig.name; + if (curr.name !== orig.name) { + const plainTablePath = unquoteSqlIdentifierPath(input.tableName); + statements.push(`EXEC sp_rename '${escapeSqlString(`${plainTablePath}.${orig.name}`)}', '${escapeSqlString(curr.name)}', 'COLUMN';`); + currentName = curr.name; + } + + if (curr.type !== orig.type || curr.nullable !== orig.nullable || Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)) { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${buildStandardColumnDefinition({ ...curr, name: currentName, default: '' }, dbType, { includeNull: true, includeIdentity: false })};`); + } + + const currDefault = normalizeDefaultText(curr.default); + const origDefault = normalizeDefaultText(orig.default); + if (currDefault !== origDefault) { + statements.push(buildSqlServerDefaultDropBatch(input.tableName, currentName)); + if (currDefault) { + statements.push(`ALTER TABLE ${tableRef}\nADD DEFAULT ${formatDefaultExpression(currDefault, dbType)} FOR ${quoteIdentifierPart(currentName, dbType)};`); + } + } + + if ((curr.comment || '') !== (orig.comment || '')) { + statements.push(buildSqlServerColumnCommentSql(input.tableName, currentName, curr.comment || '')); + } + }); + + const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key); + const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key); + const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); + if (keysChanged) { + const { objectName } = splitQualifiedName(input.tableName); + const constraintName = quoteIdentifierPart(`PK_${objectName || 'table'}`, dbType); + if (origPKKeys.length > 0) { + statements.push(`-- SQL Server 删除旧主键需要原约束名;请先在索引页确认后删除。`); + } + if (newPKKeys.length > 0) { + const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', '); + statements.push(`ALTER TABLE ${tableRef}\nADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkNames});`); + } + } + + return statements.join('\n'); +}; + +const buildSqliteAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { + const dbType = 'sqlite'; + const tableRef = quoteIdentifierPath(input.tableName, dbType); + const statements: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`); + } + }); + + input.columns.forEach((curr) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + if (!orig) { + statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`); + return; + } + + let currentName = orig.name; + if (curr.name !== orig.name) { + statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`); + currentName = curr.name; + } + if (physicalDefinitionChanged(curr, orig) || (curr.comment || '') !== (orig.comment || '')) { + statements.push(`-- SQLite 不支持直接修改字段属性,请通过创建新表、迁移数据、替换旧表的方式处理字段 ${currentName}。`); + } + }); + + return statements.join('\n'); +}; + +const buildDuckDbAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => { + const dbType = 'duckdb'; + const tableRef = quoteIdentifierPath(input.tableName, dbType); + const statements: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`); + } + }); + + input.columns.forEach((curr) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + if (!orig) { + statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`); + return; + } + + let currentName = orig.name; + if (curr.name !== orig.name) { + statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`); + currentName = curr.name; + } + if (curr.type !== orig.type) { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DATA TYPE ${curr.type};`); + } + const currDefault = normalizeDefaultText(curr.default); + const origDefault = normalizeDefaultText(orig.default); + if (currDefault !== origDefault) { + if (currDefault) { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`); + } else { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`); + } + } + if (curr.nullable !== orig.nullable) { + statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`); + } + if ((curr.comment || '') !== (orig.comment || '')) { + statements.push(`-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注,字段 ${currentName} 的备注仅保留在设计器预览中。`); + } + }); + + return statements.join('\n'); +}; + +const buildLimitedBacktickAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string, label: string): string => { + const tableRef = quoteIdentifierPath(input.tableName, dbType); + const statements: string[] = []; + + input.originalColumns.forEach((orig) => { + if (!input.columns.find((col) => col._key === orig._key)) { + statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`); + } + }); + + input.columns.forEach((curr) => { + const orig = input.originalColumns.find((col) => col._key === curr._key); + if (!orig) { + statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${quoteIdentifierPart(curr.name, dbType)} ${curr.type};`); + if (curr.nullable === 'NO' || normalizeDefaultText(curr.default) || String(curr.comment || '').trim()) { + statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`); + } + return; + } + + let currentName = orig.name; + if (curr.name !== orig.name) { + statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`); + currentName = curr.name; + } + if (curr.type !== orig.type) { + statements.push(`ALTER TABLE ${tableRef}\nMODIFY COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.type};`); + } + if ( + curr.nullable !== orig.nullable || + normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) || + (curr.comment || '') !== (orig.comment || '') || + Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement) + ) { + statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`); + } + }); + + return statements.join('\n'); +}; + +export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => { + const dbType = resolveSqlDialect(input.dbType); + if (isPgLikeDialect(dbType)) return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType); + if (isOracleLikeDialect(dbType)) return buildOracleLikeAlterPreviewSql({ ...input, dbType }, dbType); + if (isSqlServerDialect(dbType)) return buildSqlServerAlterPreviewSql({ ...input, dbType }); + if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType }); + if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType }); + if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse'); + if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine'); + if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType); + return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType); }; export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean => buildAlterTablePreviewSql(input).trim().length > 0; + +const buildCreateTableColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => { + if (isMysqlFamilyDialect(dbType)) { + return buildMySqlColumnDefinition(column, dbType); + } + if (isOracleLikeDialect(dbType)) { + return buildStandardColumnDefinition(column, dbType, { includeIdentity: true }); + } + if (isSqlServerDialect(dbType)) { + return buildStandardColumnDefinition(column, dbType, { includeNull: true, includeIdentity: true }); + } + if (dbType === 'clickhouse' || dbType === 'tdengine') { + return [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()].join(' '); + } + return buildStandardColumnDefinition(column, dbType); +}; + +const buildCreateColumnComments = (tableRef: string, input: BuildCreateTablePreviewInput, dbType: string): string[] => ( + input.columns + .filter((column) => String(column.comment || '').trim()) + .map((column) => { + if (isSqlServerDialect(dbType)) { + return buildSqlServerColumnCommentSql(input.tableName, column.name, column.comment || ''); + } + if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) { + return buildColumnCommentSql(tableRef, column.name, column.comment || '', dbType); + } + return ''; + }) + .filter(Boolean) +); + +export const buildCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): string => { + const dbType = resolveSqlDialect(input.dbType); + const tableRef = quoteIdentifierPath(input.tableName, dbType); + const colDefs = input.columns.map((column) => buildCreateTableColumnDefinition(column, dbType)); + const pkColumns = input.columns.filter((column) => column.key === 'PRI'); + if (pkColumns.length > 0) { + const pkNames = pkColumns.map((column) => quoteIdentifierPart(column.name, dbType)).join(', '); + colDefs.push(`PRIMARY KEY (${pkNames})`); + } + + const createSql = `CREATE TABLE ${tableRef} (\n ${colDefs.join(',\n ')}\n)`; + const comments = buildCreateColumnComments(tableRef, input, dbType); + + if (dbType === 'mysql' || dbType === 'mariadb') { + const charset = String(input.charset || '').trim(); + const collation = String(input.collation || '').trim(); + const charsetSql = charset ? ` DEFAULT CHARSET=${charset}` : ''; + const collationSql = collation ? ` COLLATE=${collation}` : ''; + return `${createSql} ENGINE=InnoDB${charsetSql}${collationSql};`; + } + + if (dbType === 'clickhouse') { + return `${createSql}\nENGINE = MergeTree\nORDER BY tuple();`; + } + + const suffixComments = comments.length > 0 ? `\n${comments.join('\n')}` : ''; + if (dbType === 'tdengine' && !input.columns.some((column) => /^timestamp$/i.test(String(column.type || '').trim()))) { + return `${createSql};\n-- TDengine 普通表通常需要 TIMESTAMP 时间列,执行前请确认表模型。${suffixComments}`; + } + + if (isBacktickIdentifierDialect(dbType) && dbType !== 'mysql' && dbType !== 'mariadb') { + return `${createSql};${suffixComments}`; + } + + return `${createSql};${suffixComments}`; +}; diff --git a/frontend/src/utils/sqlDialect.test.ts b/frontend/src/utils/sqlDialect.test.ts new file mode 100644 index 0000000..5baee91 --- /dev/null +++ b/frontend/src/utils/sqlDialect.test.ts @@ -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'])); + }); +}); diff --git a/frontend/src/utils/sqlDialect.ts b/frontend/src/utils/sqlDialect.ts new file mode 100644 index 0000000..54321df --- /dev/null +++ b/frontend/src/utils/sqlDialect.ts @@ -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 = (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', + 'MAP', + 'STRUCT', +]); + +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(); + 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; +};