From e01ecfc3873b4733b33f1260dd1d577865484c9e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 11 Feb 2026 17:25:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(datasource):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20DuckDB=20=E4=B8=8E=20Diros=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E5=B9=B6=E8=A1=A5=E9=BD=90=20DuckDB=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DuckDB 与 Diros 后端驱动实现并接入数据库工厂 - 前端连接配置补充 DuckDB/Diros 入口及方言映射 - 侧边栏支持 DuckDB Macro 函数列表加载与对象分组展示 - 定义查看器支持 DuckDB 函数定义查询与 DDL 还原 - 后端补充 DuckDB 函数删除分支并限制存储过程操作 --- frontend/src/components/ConnectionModal.tsx | 95 +++- frontend/src/components/DataViewer.tsx | 2 +- frontend/src/components/DefinitionViewer.tsx | 84 +++- frontend/src/components/QueryEditor.tsx | 2 +- frontend/src/components/Sidebar.tsx | 110 ++++- frontend/src/components/TableDesigner.tsx | 2 +- frontend/src/components/TriggerViewer.tsx | 8 +- frontend/src/store.ts | 6 + frontend/src/utils/sql.ts | 4 +- go.mod | 16 +- go.sum | 44 +- internal/app/db_context.go | 2 +- internal/app/methods_db.go | 27 +- internal/app/methods_file.go | 4 +- internal/db/database.go | 4 + internal/db/diros_impl.go | 218 +++++++++ internal/db/duckdb_impl.go | 460 +++++++++++++++++++ internal/db/mysql_impl.go | 3 +- internal/sync/sql_helpers.go | 4 +- 19 files changed, 1035 insertions(+), 60 deletions(-) create mode 100644 internal/db/diros_impl.go create mode 100644 internal/db/duckdb_impl.go diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index f64b155..fb4efc5 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -14,6 +14,7 @@ const MAX_TIMEOUT_SECONDS = 3600; const getDefaultPortByType = (type: string) => { switch (type) { case 'mysql': return 3306; + case 'diros': return 9030; case 'sphinx': return 9306; case 'postgres': return 5432; case 'redis': return 6379; @@ -26,10 +27,13 @@ const getDefaultPortByType = (type: string) => { case 'highgo': return 5866; case 'mariadb': return 3306; case 'vastbase': return 5432; + case 'duckdb': return 0; default: return 3306; } }; +const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb'; + const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); @@ -209,9 +213,11 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal return null; } - if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') { + if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const mysqlDefaultPort = getDefaultPortByType(type); - const parsed = parseMultiHostUri(trimmedUri, 'mysql'); + const parsed = parseMultiHostUri(trimmedUri, 'mysql') + || parseMultiHostUri(trimmedUri, 'diros') + || parseMultiHostUri(trimmedUri, 'doris'); if (!parsed) { return null; } @@ -242,6 +248,41 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal }; } + if (isFileDatabaseType(type)) { + const tryExtractPath = (uri: string, scheme: string): string | null => { + const parsed = parseMultiHostUri(uri, scheme); + if (!parsed) { + return null; + } + const host = String(parsed.hosts?.[0] || '').trim(); + const dbPath = String(parsed.database || '').trim(); + if (host && dbPath) { + return `/${host}/${dbPath}`.replace(/\/+/g, '/'); + } + if (host) { + return `/${host}`.replace(/\/+/g, '/'); + } + if (dbPath) { + return dbPath.startsWith('/') ? dbPath : `/${dbPath}`; + } + return null; + }; + + const pathFromScheme = tryExtractPath(trimmedUri, type); + if (pathFromScheme) { + return { host: decodeURIComponent(pathFromScheme) }; + } + + const rawPath = trimmedUri + .replace(/^sqlite:\/\//i, '') + .replace(/^duckdb:\/\//i, '') + .trim(); + if (!rawPath) { + return null; + } + return { host: decodeURIComponent(rawPath) }; + } + if (type === 'mongodb') { const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv'); if (!parsed) { @@ -305,9 +346,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal }); const getUriPlaceholder = () => { - if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'sphinx') { + if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') { const defaultPort = getDefaultPortByType(dbType); - return `mysql://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; + const scheme = dbType === 'diros' ? 'diros' : 'mysql'; + return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; + } + if (isFileDatabaseType(dbType)) { + return dbType === 'duckdb' + ? 'duckdb:///Users/name/demo.duckdb' + : 'sqlite:///Users/name/demo.sqlite'; } if (dbType === 'mongodb') { return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256'; @@ -328,7 +375,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal ? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@` : ''; - if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') { + if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const primary = toAddress(host, port, defaultPort); const replicas = values.mysqlTopology === 'replica' ? normalizeAddressList(values.mysqlReplicaHosts, defaultPort) @@ -343,7 +390,17 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal } const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; const query = params.toString(); - return `mysql://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; + const scheme = type === 'diros' ? 'diros' : 'mysql'; + return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; + } + + if (isFileDatabaseType(type)) { + const pathText = String(values.host || '').trim(); + if (!pathText) { + return `${type}://`; + } + const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`; + return `${type}://${encodeURI(normalizedPath)}`; } if (type === 'mongodb') { @@ -463,7 +520,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal ); const primaryHost = primaryAddress?.host || String(config.host || 'localhost'); const primaryPort = primaryAddress?.port || Number(config.port || defaultPort); - const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; + const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet; @@ -539,7 +596,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const isRedisType = values.type === 'redis'; const newConn = { id: initialValues ? initialValues.id : Date.now().toString(), - name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), + name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), config: config, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined @@ -710,7 +767,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal ? mergedValues.savePassword !== false : true; - if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') { + if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { const replicas = mergedValues.mysqlTopology === 'replica' ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) : []; @@ -793,7 +850,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); - if (type !== 'sqlite' && type !== 'custom') { + if (!isFileDatabaseType(type) && type !== 'custom') { form.setFieldsValue({ port: defaultPort, mysqlTopology: 'single', @@ -817,7 +874,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal setStep(2); }; - const isSqlite = dbType === 'sqlite'; + const isFileDb = isFileDatabaseType(dbType); const isCustom = dbType === 'custom'; const isRedis = dbType === 'redis'; @@ -825,10 +882,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal { label: '关系型数据库', items: [ { key: 'mysql', name: 'MySQL', icon: }, { key: 'mariadb', name: 'MariaDB', icon: }, + { key: 'diros', name: 'Diros', icon: }, { key: 'sphinx', name: 'Sphinx', icon: }, { key: 'postgres', name: 'PostgreSQL', icon: }, { key: 'sqlserver', name: 'SQL Server', icon: }, { key: 'sqlite', name: 'SQLite', icon: }, + { key: 'duckdb', name: 'DuckDB', icon: }, { key: 'oracle', name: 'Oracle', icon: }, ]}, { label: '国产数据库', items: [ @@ -988,16 +1047,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
- {!isSqlite && ( + {!isFileDb && ( void; initialVal )}
- {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'sphinx') && ( + {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( <> {dbList.map(db => {db})} @@ -1217,7 +1276,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal )} - {!isSqlite && ( + {!isFileDb && ( <> diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 39f4652..cedcf41 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -61,7 +61,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const dbType = config.type || ''; const dbTypeLower = String(dbType || '').trim().toLowerCase(); - const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb'; + const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros'; const dbName = tab.dbName || ''; const tableName = tab.tableName || ''; diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index cf57396..9072258 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -23,9 +23,11 @@ const DefinitionViewer: React.FC = ({ tab }) => { const getMetadataDialect = (conn: any): string => { const type = String(conn?.config?.type || '').trim().toLowerCase(); if (type === 'custom') { - return String(conn?.config?.driver || '').trim().toLowerCase(); + const driver = String(conn?.config?.driver || '').trim().toLowerCase(); + if (driver === 'diros' || driver === 'doris') return 'mysql'; + return driver; } - if (type === 'mariadb' || type === 'sphinx') return 'mysql'; + if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; }; @@ -47,6 +49,55 @@ const DefinitionViewer: React.FC = ({ tab }) => { return { schema: '', name: raw }; }; + const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; + }; + + const parseDuckDBParameterNames = (raw: any): string[] => { + if (Array.isArray(raw)) { + return raw + .map((item) => String(item ?? '').trim()) + .filter((item) => item !== '' && item.toLowerCase() !== ''); + } + const text = String(raw ?? '').trim(); + if (!text) return []; + const normalized = text.startsWith('[') && text.endsWith(']') + ? text.slice(1, -1) + : text; + return normalized + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '' && part.toLowerCase() !== ''); + }; + + const buildDuckDBMacroDDL = ( + schemaName: string, + functionName: string, + parametersRaw: any, + macroDefinitionRaw: any + ): string => { + const schema = String(schemaName || '').trim(); + const name = String(functionName || '').trim(); + const macroDefinition = String(macroDefinitionRaw || '').trim(); + if (!name || !macroDefinition) return ''; + + const parameters = parseDuckDBParameterNames(parametersRaw).join(', '); + const qualifiedName = schema ? `${schema}.${name}` : name; + const isTableMacro = !macroDefinition.startsWith('('); + if (isTableMacro) { + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; + } + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`; + }; + const buildShowViewQueries = (dialect: string, viewName: string, dbName: string): string[] => { const { schema, name } = parseSchemaAndName(viewName); const safeName = escapeSQLLiteral(name); @@ -81,6 +132,10 @@ const DefinitionViewer: React.FC = ({ tab }) => { return [`SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`]; case 'sqlite': return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`]; + case 'duckdb': { + const schemaRef = schema || 'main'; + return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`]; + } default: return [`-- 暂不支持该数据库类型的视图定义查看`]; } @@ -120,8 +175,16 @@ const DefinitionViewer: React.FC = ({ tab }) => { } return [`SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`]; } + case 'duckdb': { + const schemaRef = schema || 'main'; + const safeSchema = escapeSQLLiteral(schemaRef); + return [ + `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${safeSchema}' AND function_name = '${safeName}' LIMIT 1`, + `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND function_name = '${safeName}' ORDER BY CASE WHEN schema_name = '${safeSchema}' THEN 0 ELSE 1 END, schema_name LIMIT 1`, + ]; + } case 'sqlite': - return [`-- SQLite 不支持存储函数/存储过程`]; + return [`-- SQLite 不支持函数/存储过程定义管理`]; default: return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`]; } @@ -244,6 +307,21 @@ const DefinitionViewer: React.FC = ({ tab }) => { // Oracle/DM ALL_SOURCE returns multiple rows, one per line return data.map(row => row.text || row.TEXT || Object.values(row)[0] || '').join(''); } + case 'duckdb': { + const row = data[0] as Record; + const ddl = buildDuckDBMacroDDL( + String(getCaseInsensitiveRawValue(row, ['schema_name']) || '').trim(), + String(getCaseInsensitiveRawValue(row, ['function_name', 'routine_name', 'name']) || '').trim(), + getCaseInsensitiveRawValue(row, ['parameters']), + getCaseInsensitiveRawValue(row, ['macro_definition']) + ); + if (ddl) return ddl; + const fallback = getCaseInsensitiveRawValue(row, ['macro_definition', 'routine_definition', 'definition']); + if (fallback !== undefined && fallback !== null && String(fallback).trim() !== '') { + return String(fallback); + } + return JSON.stringify(row, null, 2); + } default: { const row = data[0]; return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || ''; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index d602616..69d4199 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -922,7 +922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => { const normalizedType = (dbType || 'mysql').toLowerCase(); - const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'tdengine' || normalizedType === ''; + const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === ''; if (!supportsLimit) return { sql, applied: false, maxRows }; if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 778f871..4517382 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -228,9 +228,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const getMetadataDialect = (conn: SavedConnection | undefined): string => { const type = String(conn?.config?.type || '').trim().toLowerCase(); if (type === 'custom') { - return String((conn?.config as any)?.driver || '').trim().toLowerCase(); + const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase(); + if (driver === 'diros' || driver === 'doris') return 'mysql'; + return driver; } - if (type === 'mariadb' || type === 'sphinx') return 'mysql'; + if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; }; @@ -283,6 +285,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return ''; }; + const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; + }; + const getFirstRowValue = (row: Record): string => { for (const value of Object.values(row || {})) { if (value !== undefined && value !== null) { @@ -326,6 +340,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; }; + const parseDuckDBParameterNames = (raw: any): string[] => { + if (Array.isArray(raw)) { + return raw + .map((item) => String(item ?? '').trim()) + .filter((item) => item !== '' && item.toLowerCase() !== ''); + } + + const text = String(raw ?? '').trim(); + if (!text) return []; + const normalized = text.startsWith('[') && text.endsWith(']') + ? text.slice(1, -1) + : text; + return normalized + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '' && part.toLowerCase() !== ''); + }; + + const buildDuckDBMacroDDL = ( + schemaName: string, + functionName: string, + parametersRaw: any, + macroDefinitionRaw: any + ): string => { + const schema = String(schemaName || '').trim(); + const name = String(functionName || '').trim(); + const macroDefinition = String(macroDefinitionRaw || '').trim(); + if (!name || !macroDefinition) return ''; + + const parameters = parseDuckDBParameterNames(parametersRaw).join(', '); + const qualifiedName = schema ? `${schema}.${name}` : name; + const isTableMacro = !macroDefinition.startsWith('('); + if (isTableMacro) { + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; + } + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`; + }; + const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { const safeDbName = escapeSQLLiteral(dbName); switch (dialect) { @@ -358,6 +410,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return [{ sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` }]; case 'sqlite': return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }]; + case 'duckdb': + return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }]; default: return []; } @@ -395,6 +449,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }]; case 'sqlite': return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }]; + case 'duckdb': + return []; default: return []; } @@ -438,6 +494,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return [{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }]; } return [{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }]; + case 'duckdb': + return [{ + sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, + inferredType: 'FUNCTION', + }]; default: return []; } @@ -1697,6 +1758,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> case 'sqlite': query = `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`; break; + case 'duckdb': { + const parts = splitQualifiedName(viewName); + const viewSchema = escapeSQLLiteral(parts.schemaName || 'main'); + const viewObject = escapeSQLLiteral(parts.objectName || viewName); + query = `SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`; + break; + } } if (query) { const result = await DBQuery(config as any, dbName, query); @@ -1739,6 +1807,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; case 'sqlite': + case 'duckdb': template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; default: @@ -1831,9 +1900,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> try { const config = buildRuntimeConfig(conn, dbName); let query = ''; - const parts = routineName.split('.'); - const name = parts.length > 1 ? parts[1] : routineName; - const schema = parts.length > 1 ? parts[0] : ''; + const parsedRoutine = splitQualifiedName(routineName); + const name = parsedRoutine.objectName || routineName; + const schema = parsedRoutine.schemaName; switch (dialect) { case 'mysql': @@ -1856,6 +1925,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } break; } + case 'duckdb': { + const schemaRef = schema || 'main'; + query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`; + break; + } } if (query) { const result = await DBQuery(config as any, dbName, query); @@ -1863,6 +1937,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> if (dialect === 'oracle' || dialect === 'dm') { const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join(''); if (lines) template = `-- 编辑${typeLabel} ${routineName}\nCREATE OR REPLACE ${lines}`; + } else if (dialect === 'duckdb') { + const row = result.data[0] as Record; + const ddl = buildDuckDBMacroDDL( + String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(), + String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(), + getCaseInsensitiveRawValue(row, ['parameters']), + getCaseInsensitiveRawValue(row, ['macro_definition']) + ); + if (ddl) template = `-- 编辑${typeLabel} ${routineName}\n${ddl}`; } else { const row = result.data[0] as Record; const def = row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || ''; @@ -1910,6 +1993,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;` : `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`; break; + case 'duckdb': + template = isProc + ? `-- DuckDB 暂不支持存储过程\n-- 请使用 SQL Macro 作为函数能力\nCREATE MACRO func_name(param1) AS (param1 * 2);` + : `CREATE MACRO func_name(param1) AS (param1 * 2);`; + break; default: template = isProc ? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;` @@ -2031,20 +2119,24 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> // 函数分组节点的右键菜单 if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') { - return [ + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const routineMenu: MenuProps['items'] = [ { key: 'create-function', label: '新建函数', icon: , onClick: () => openCreateRoutine(node, 'FUNCTION') }, - { + ]; + if (dialect !== 'duckdb') { + routineMenu.push({ key: 'create-procedure', label: '新建存储过程', icon: , onClick: () => openCreateRoutine(node, 'PROCEDURE') - }, - ]; + }); + } + return routineMenu; } if (node.type === 'connection') { diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 0e0c3ea..4b6b804 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -479,7 +479,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const getDbType = (): string => { const conn = connections.find(c => c.id === tab.connectionId); const type = String(conn?.config?.type || '').toLowerCase(); - if (type === 'mariadb' || type === 'sphinx') return 'mysql'; + if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; }; diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx index ec75208..d0a91be 100644 --- a/frontend/src/components/TriggerViewer.tsx +++ b/frontend/src/components/TriggerViewer.tsx @@ -50,9 +50,11 @@ const TriggerViewer: React.FC = ({ tab }) => { const getMetadataDialect = (conn: any): string => { const type = String(conn?.config?.type || '').trim().toLowerCase(); if (type === 'custom') { - return String(conn?.config?.driver || '').trim().toLowerCase(); + const driver = String(conn?.config?.driver || '').trim().toLowerCase(); + if (driver === 'diros' || driver === 'doris') return 'mysql'; + return driver; } - if (type === 'mariadb' || type === 'sphinx') return 'mysql'; + if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; }; @@ -100,6 +102,8 @@ LIMIT 1`]; return [`SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`]; case 'sqlite': return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`]; + case 'duckdb': + return [`-- DuckDB 不支持触发器`]; case 'tdengine': return [`-- TDengine 不支持触发器`]; case 'mongodb': diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 161dfca..109f9d2 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -14,6 +14,7 @@ const DEFAULT_CONNECTION_TYPE = 'mysql'; const SUPPORTED_CONNECTION_TYPES = new Set([ 'mysql', 'mariadb', + 'diros', 'sphinx', 'postgres', 'redis', @@ -26,6 +27,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([ 'highgo', 'vastbase', 'sqlite', + 'duckdb', 'custom', ]); @@ -34,6 +36,10 @@ const getDefaultPortByType = (type: string): number => { case 'mysql': case 'mariadb': return 3306; + case 'diros': + return 9030; + case 'duckdb': + return 0; case 'sphinx': return 9306; case 'postgres': diff --git a/frontend/src/utils/sql.ts b/frontend/src/utils/sql.ts index e3ba3ad..40f5577 100644 --- a/frontend/src/utils/sql.ts +++ b/frontend/src/utils/sql.ts @@ -36,7 +36,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => { if (!raw) return raw; const dbTypeLower = (dbType || '').toLowerCase(); - if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') { + if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') { return `\`${raw.replace(/`/g, '``')}\``; } @@ -111,7 +111,7 @@ export const buildOrderBySQL = ( // MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort, // 导致 `Error 1038 (HY001): Out of sort memory`。 // 因此仅在用户主动点击排序时下发 ORDER BY,默认分页查询不加兜底排序。 - if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb') { + if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros') { return ''; } diff --git a/go.mod b/go.mod index 48dad80..ec0fa20 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-sql-driver/mysql v1.9.3 github.com/highgo/pq-sm3 v0.0.0 github.com/lib/pq v1.11.1 + github.com/marcboeker/go-duckdb v1.8.5 github.com/microsoft/go-mssqldb v1.9.6 github.com/redis/go-redis/v9 v9.17.3 github.com/sijms/go-ora/v2 v2.9.0 @@ -22,21 +23,26 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/apache/arrow-go/v18 v18.1.0 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/flatbuffers v25.1.24+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect @@ -45,9 +51,10 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -68,10 +75,15 @@ require ( github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + golang.org/x/tools v0.40.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d11cbb5..f3e23a7 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,12 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= +github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -35,6 +41,10 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -46,6 +56,8 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o= +github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -64,8 +76,12 @@ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4P github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= @@ -84,6 +100,8 @@ github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= +github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= @@ -94,12 +112,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -126,8 +151,9 @@ github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -165,6 +191,10 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/T github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -200,6 +230,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= @@ -218,6 +250,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/db_context.go b/internal/app/db_context.go index efed723..7f92849 100644 --- a/internal/app/db_context.go +++ b/internal/app/db_context.go @@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne } switch strings.ToLower(strings.TrimSpace(config.Type)) { - case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine": // 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。 runConfig.Database = name case "dameng": diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 76cd4cf..5d8b382 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -88,7 +88,7 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName) } else if dbType == "tdengine" { query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName)) - } else if dbType == "mariadb" { + } else if dbType == "mariadb" || dbType == "diros" { // MariaDB uses same syntax as MySQL } else if dbType == "sphinx" { return connection.QueryResult{Success: false, Message: "Sphinx 暂不支持创建数据库"} @@ -118,6 +118,8 @@ func resolveDDLDBType(config connection.ConnectionConfig) string { return "sqlite" case "sphinxql": return "sphinx" + case "diros", "doris": + return "diros" default: return driver } @@ -160,7 +162,7 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN if strings.EqualFold(strings.TrimSpace(config.Type), "custom") { // custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。 switch dbType { - case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "vastbase", "dameng": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng": if strings.TrimSpace(dbName) != "" { runConfig.Database = strings.TrimSpace(dbName) } @@ -181,8 +183,8 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "sphinx": - return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"} + case "mysql", "mariadb", "diros", "sphinx": + return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/Diros/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"} case "postgres", "kingbase", "highgo", "vastbase": if strings.EqualFold(strings.TrimSpace(config.Database), oldName) { return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"} @@ -217,7 +219,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co sql string ) switch dbType { - case "mysql", "mariadb", "tdengine": + case "mysql", "mariadb", "diros", "tdengine": runConfig = config runConfig.Database = "" sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName)) @@ -259,7 +261,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)} } @@ -273,7 +275,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old var sql string switch dbType { - case "mysql", "mariadb", "sphinx": + case "mysql", "mariadb", "diros", "sphinx": newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName) sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable) case "sqlserver": @@ -305,7 +307,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)} } @@ -556,7 +558,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)} } @@ -591,10 +593,13 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro dbType := resolveDDLDBType(config) switch dbType { - case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "sqlserver": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "duckdb": default: return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除函数/存储过程", dbType)} } + if dbType == "duckdb" && routineType == "PROCEDURE" { + return connection.QueryResult{Success: false, Message: "DuckDB 暂不支持存储过程"} + } schemaName, pureName := normalizeSchemaAndTableByType(dbType, dbName, routineName) if pureName == "" { @@ -642,7 +647,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN var sql string switch dbType { - case "mysql", "mariadb", "sphinx": + case "mysql", "mariadb", "diros", "sphinx": newQualified := quoteTableIdentByType(dbType, schemaName, newName) sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified) case "postgres", "kingbase", "highgo", "vastbase": diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 79eb564..ebf9dba 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -655,7 +655,7 @@ func quoteIdentByType(dbType string, ident string) string { } switch dbType { - case "mysql", "mariadb", "sphinx", "tdengine": + case "mysql", "mariadb", "diros", "sphinx", "tdengine": return "`" + strings.ReplaceAll(ident, "`", "``") + "`" case "sqlserver": escaped := strings.ReplaceAll(ident, "]", "]]") @@ -787,7 +787,7 @@ func formatSQLValue(dbType string, v interface{}) string { case time.Time: return "'" + val.Format("2006-01-02 15:04:05") + "'" case string: - if strings.ToLower(strings.TrimSpace(dbType)) == "mysql" && isMySQLHexLiteral(val) { + if (strings.ToLower(strings.TrimSpace(dbType)) == "mysql" || strings.ToLower(strings.TrimSpace(dbType)) == "diros") && isMySQLHexLiteral(val) { return val } escaped := strings.ReplaceAll(val, "'", "''") diff --git a/internal/db/database.go b/internal/db/database.go index 3855207..9836e4d 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -48,12 +48,16 @@ func NewDatabase(dbType string) (Database, error) { return &HighGoDB{}, nil case "mariadb": return &MariaDB{}, nil + case "diros", "doris": + return &DirosDB{}, nil case "sphinx": return &SphinxDB{}, nil case "vastbase": return &VastbaseDB{}, nil case "tdengine": return &TDengineDB{}, nil + case "duckdb": + return &DuckDB{}, nil case "custom": return &CustomDB{}, nil default: diff --git a/internal/db/diros_impl.go b/internal/db/diros_impl.go new file mode 100644 index 0000000..44f2a15 --- /dev/null +++ b/internal/db/diros_impl.go @@ -0,0 +1,218 @@ +package db + +import ( + "database/sql" + "fmt" + "net/url" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/ssh" + "GoNavi-Wails/internal/utils" + + mysqlDriver "github.com/go-sql-driver/mysql" +) + +const ( + dirosDriverName = "diros" + defaultDirosPort = 9030 +) + +// DirosDB 使用独立 driver 名称(diros)接入,底层协议兼容 MySQL。 +type DirosDB struct { + MySQLDB +} + +func init() { + for _, name := range sql.Drivers() { + if name == dirosDriverName { + return + } + } + sql.Register(dirosDriverName, &mysqlDriver.MySQLDriver{}) +} + +func applyDirosURI(config connection.ConnectionConfig) connection.ConnectionConfig { + uriText := strings.TrimSpace(config.URI) + if uriText == "" { + return config + } + + lowerURI := strings.ToLower(uriText) + if !strings.HasPrefix(lowerURI, "diros://") && + !strings.HasPrefix(lowerURI, "doris://") && + !strings.HasPrefix(lowerURI, "mysql://") { + return config + } + + parsed, err := url.Parse(uriText) + if err != nil { + return config + } + + if parsed.User != nil { + if config.User == "" { + config.User = parsed.User.Username() + } + if pass, ok := parsed.User.Password(); ok && config.Password == "" { + config.Password = pass + } + } + + if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" && config.Database == "" { + config.Database = dbName + } + + defaultPort := config.Port + if defaultPort <= 0 { + defaultPort = defaultDirosPort + } + + hostsFromURI := make([]string, 0, 4) + hostText := strings.TrimSpace(parsed.Host) + if hostText != "" { + for _, entry := range strings.Split(hostText, ",") { + host, port, ok := parseHostPortWithDefault(entry, defaultPort) + if !ok { + continue + } + hostsFromURI = append(hostsFromURI, normalizeMySQLAddress(host, port)) + } + } + + if len(config.Hosts) == 0 && len(hostsFromURI) > 0 { + config.Hosts = hostsFromURI + } + if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 { + host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort) + if ok { + config.Host = host + config.Port = port + } + } + + if config.Topology == "" { + topology := strings.TrimSpace(parsed.Query().Get("topology")) + if topology != "" { + config.Topology = strings.ToLower(topology) + } + } + + return config +} + +func collectDirosAddresses(config connection.ConnectionConfig) []string { + defaultPort := config.Port + if defaultPort <= 0 { + defaultPort = defaultDirosPort + } + + candidates := make([]string, 0, len(config.Hosts)+1) + if len(config.Hosts) > 0 { + candidates = append(candidates, config.Hosts...) + } else { + candidates = append(candidates, normalizeMySQLAddress(config.Host, defaultPort)) + } + + result := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, entry := range candidates { + host, port, ok := parseHostPortWithDefault(entry, defaultPort) + if !ok { + continue + } + normalized := normalizeMySQLAddress(host, port) + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + + return result +} + +func (d *DirosDB) getDSN(config connection.ConnectionConfig) string { + database := config.Database + protocol := "tcp" + address := normalizeMySQLAddress(config.Host, config.Port) + + if config.UseSSH { + netName, err := ssh.RegisterSSHNetwork(config.SSH) + if err == nil { + protocol = netName + address = normalizeMySQLAddress(config.Host, config.Port) + } else { + logger.Warnf("注册 Diros SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s,原因:%v", config.Host, config.Port, config.User, err) + } + } + + timeout := getConnectTimeoutSeconds(config) + + return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds", + config.User, config.Password, protocol, address, database, timeout) +} + +func resolveDirosCredential(config connection.ConnectionConfig, addressIndex int) (string, string) { + primaryUser := strings.TrimSpace(config.User) + primaryPassword := config.Password + replicaUser := strings.TrimSpace(config.MySQLReplicaUser) + replicaPassword := config.MySQLReplicaPassword + + if addressIndex > 0 && replicaUser != "" { + return replicaUser, replicaPassword + } + + if primaryUser == "" && replicaUser != "" { + return replicaUser, replicaPassword + } + + return config.User, primaryPassword +} + +func (d *DirosDB) Connect(config connection.ConnectionConfig) error { + runConfig := applyDirosURI(config) + addresses := collectDirosAddresses(runConfig) + if len(addresses) == 0 { + return fmt.Errorf("连接建立后验证失败:未找到可用的 Diros 地址") + } + + var errorDetails []string + for index, address := range addresses { + candidateConfig := runConfig + host, port, ok := parseHostPortWithDefault(address, defaultDirosPort) + if !ok { + continue + } + candidateConfig.Host = host + candidateConfig.Port = port + candidateConfig.User, candidateConfig.Password = resolveDirosCredential(runConfig, index) + + dsn := d.getDSN(candidateConfig) + db, err := sql.Open(dirosDriverName, dsn) + if err != nil { + errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err)) + continue + } + + timeout := getConnectTimeout(candidateConfig) + ctx, cancel := utils.ContextWithTimeout(timeout) + pingErr := db.PingContext(ctx) + cancel() + if pingErr != nil { + _ = db.Close() + errorDetails = append(errorDetails, fmt.Sprintf("%s 验证失败: %v", address, pingErr)) + continue + } + + d.conn = db + d.pingTimeout = timeout + return nil + } + + if len(errorDetails) == 0 { + return fmt.Errorf("连接建立后验证失败:未找到可用的 Diros 地址") + } + return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ";")) +} diff --git a/internal/db/duckdb_impl.go b/internal/db/duckdb_impl.go new file mode 100644 index 0000000..ec7f1c1 --- /dev/null +++ b/internal/db/duckdb_impl.go @@ -0,0 +1,460 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/utils" + + _ "github.com/marcboeker/go-duckdb" +) + +type DuckDB struct { + conn *sql.DB + pingTimeout time.Duration +} + +func (d *DuckDB) Connect(config connection.ConnectionConfig) error { + dsn := strings.TrimSpace(config.Host) + if dsn == "" { + dsn = strings.TrimSpace(config.Database) + } + if dsn == "" { + dsn = ":memory:" + } + + db, err := sql.Open("duckdb", dsn) + if err != nil { + return fmt.Errorf("打开数据库连接失败:%w", err) + } + d.conn = db + d.pingTimeout = getConnectTimeout(config) + + if err := d.Ping(); err != nil { + return fmt.Errorf("连接建立后验证失败:%w", err) + } + return nil +} + +func (d *DuckDB) Close() error { + if d.conn != nil { + return d.conn.Close() + } + return nil +} + +func (d *DuckDB) Ping() error { + if d.conn == nil { + return fmt.Errorf("connection not open") + } + timeout := d.pingTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + return d.conn.PingContext(ctx) +} + +func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + if d.conn == nil { + return nil, nil, fmt.Errorf("connection not open") + } + rows, err := d.conn.QueryContext(ctx, query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + return scanRows(rows) +} + +func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error) { + if d.conn == nil { + return nil, nil, fmt.Errorf("connection not open") + } + rows, err := d.conn.Query(query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + return scanRows(rows) +} + +func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) { + if d.conn == nil { + return 0, fmt.Errorf("connection not open") + } + res, err := d.conn.ExecContext(ctx, query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (d *DuckDB) Exec(query string) (int64, error) { + if d.conn == nil { + return 0, fmt.Errorf("connection not open") + } + res, err := d.conn.Exec(query) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (d *DuckDB) GetDatabases() ([]string, error) { + data, _, err := d.Query("PRAGMA database_list") + if err != nil { + return []string{"main"}, nil + } + + seen := map[string]struct{}{} + var names []string + for _, row := range data { + name := strings.TrimSpace(duckDBRowString(row, "name", "database_name", "database")) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + if len(names) == 0 { + return []string{"main"}, nil + } + return names, nil +} + +func (d *DuckDB) GetTables(dbName string) ([]string, error) { + query := ` +SELECT table_schema, table_name +FROM information_schema.tables +WHERE table_type = 'BASE TABLE' + AND table_schema NOT IN ('information_schema', 'pg_catalog') +ORDER BY table_schema, table_name` + + data, _, err := d.Query(query) + if err != nil { + return nil, err + } + + seen := map[string]struct{}{} + var tables []string + for _, row := range data { + schema := strings.TrimSpace(duckDBRowString(row, "table_schema")) + name := strings.TrimSpace(duckDBRowString(row, "table_name")) + if name == "" { + continue + } + qualified := name + if schema != "" && !strings.EqualFold(schema, "main") { + qualified = schema + "." + name + } + if _, exists := seen[qualified]; exists { + continue + } + seen[qualified] = struct{}{} + tables = append(tables, qualified) + } + return tables, nil +} + +func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) { + schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName) + if pureTable == "" { + return "", fmt.Errorf("table name required") + } + + escapedTable := escapeDuckDBLiteral(pureTable) + escapedSchema := escapeDuckDBLiteral(schema) + + queryCandidates := []string{ + fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' AND schema_name = '%s' LIMIT 1", escapedTable, escapedSchema), + fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' LIMIT 1", escapedTable), + fmt.Sprintf("SHOW CREATE TABLE %s", quoteDuckDBQualifiedTable(schema, pureTable)), + } + + for _, query := range queryCandidates { + data, _, err := d.Query(query) + if err != nil || len(data) == 0 { + continue + } + + createSQL := strings.TrimSpace(duckDBRowString(data[0], "sql", "create_table", "Create Table", "create_statement")) + if createSQL != "" { + return createSQL, nil + } + for _, value := range data[0] { + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text != "" && text != "" { + return text, nil + } + } + } + + return "", fmt.Errorf("create statement not found") +} + +func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName) + if pureTable == "" { + return nil, fmt.Errorf("table name required") + } + + query := fmt.Sprintf(` +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = '%s' AND table_schema = '%s' +ORDER BY ordinal_position`, escapeDuckDBLiteral(pureTable), escapeDuckDBLiteral(schema)) + + data, _, err := d.Query(query) + if err != nil { + return nil, err + } + if len(data) == 0 && schema != "main" { + fallbackQuery := fmt.Sprintf(` +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = '%s' +ORDER BY ordinal_position`, escapeDuckDBLiteral(pureTable)) + data, _, err = d.Query(fallbackQuery) + if err != nil { + return nil, err + } + } + + var columns []connection.ColumnDefinition + for _, row := range data { + column := connection.ColumnDefinition{ + Name: duckDBRowString(row, "column_name"), + Type: duckDBRowString(row, "data_type"), + Nullable: strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "is_nullable"))), + Key: "", + Extra: "", + Comment: "", + } + if column.Nullable == "" { + column.Nullable = "YES" + } + if defaultVal := strings.TrimSpace(duckDBRowString(row, "column_default")); defaultVal != "" && defaultVal != "" { + def := defaultVal + column.Default = &def + } + columns = append(columns, column) + } + return columns, nil +} + +func (d *DuckDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + query := ` +SELECT table_schema, table_name, column_name, data_type +FROM information_schema.columns +WHERE table_schema NOT IN ('information_schema', 'pg_catalog') +ORDER BY table_schema, table_name, ordinal_position` + + data, _, err := d.Query(query) + if err != nil { + return nil, err + } + + columns := make([]connection.ColumnDefinitionWithTable, 0, len(data)) + for _, row := range data { + schema := strings.TrimSpace(duckDBRowString(row, "table_schema")) + tableName := strings.TrimSpace(duckDBRowString(row, "table_name")) + if tableName == "" { + continue + } + if schema != "" && !strings.EqualFold(schema, "main") { + tableName = schema + "." + tableName + } + + columns = append(columns, connection.ColumnDefinitionWithTable{ + TableName: tableName, + Name: duckDBRowString(row, "column_name"), + Type: duckDBRowString(row, "data_type"), + }) + } + return columns, nil +} + +func (d *DuckDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return []connection.IndexDefinition{}, nil +} + +func (d *DuckDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return []connection.ForeignKeyDefinition{}, nil +} + +func (d *DuckDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return []connection.TriggerDefinition{}, nil +} + +func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { + if d.conn == nil { + return fmt.Errorf("connection not open") + } + + tx, err := d.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + quoteIdent := func(name string) string { + n := strings.TrimSpace(name) + n = strings.Trim(n, "\"") + n = strings.ReplaceAll(n, "\"", "\"\"") + if n == "" { + return "\"\"" + } + return `"` + n + `"` + } + + schema := "" + table := strings.TrimSpace(tableName) + if parts := strings.SplitN(table, ".", 2); len(parts) == 2 { + schema = strings.TrimSpace(parts[0]) + table = strings.TrimSpace(parts[1]) + } + + qualifiedTable := quoteIdent(table) + if schema != "" { + qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table)) + } + + for _, pk := range changes.Deletes { + var wheres []string + var args []interface{} + for k, v := range pk { + wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k))) + args = append(args, v) + } + if len(wheres) == 0 { + continue + } + query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND ")) + if _, err := tx.Exec(query, args...); err != nil { + return fmt.Errorf("delete error: %v", err) + } + } + + for _, update := range changes.Updates { + var sets []string + var args []interface{} + for k, v := range update.Values { + sets = append(sets, fmt.Sprintf("%s = ?", quoteIdent(k))) + args = append(args, v) + } + if len(sets) == 0 { + continue + } + + var wheres []string + for k, v := range update.Keys { + wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k))) + args = append(args, v) + } + if len(wheres) == 0 { + return fmt.Errorf("update requires keys") + } + + query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND ")) + if _, err := tx.Exec(query, args...); err != nil { + return fmt.Errorf("update error: %v", err) + } + } + + for _, row := range changes.Inserts { + var cols []string + var placeholders []string + var args []interface{} + + for k, v := range row { + cols = append(cols, quoteIdent(k)) + placeholders = append(placeholders, "?") + args = append(args, v) + } + if len(cols) == 0 { + continue + } + + query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", ")) + if _, err := tx.Exec(query, args...); err != nil { + return fmt.Errorf("insert error: %v", err) + } + } + + return tx.Commit() +} + +func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) { + schema := strings.TrimSpace(dbName) + table := strings.TrimSpace(tableName) + if table == "" { + if schema == "" { + schema = "main" + } + return schema, table + } + + if parts := strings.SplitN(table, ".", 2); len(parts) == 2 { + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + if left != "" && right != "" { + return normalizeDuckDBIdentifier(left), normalizeDuckDBIdentifier(right) + } + } + + if schema == "" { + schema = "main" + } + return normalizeDuckDBIdentifier(schema), normalizeDuckDBIdentifier(table) +} + +func normalizeDuckDBIdentifier(raw string) string { + text := strings.TrimSpace(raw) + if len(text) >= 2 { + first := text[0] + last := text[len(text)-1] + if (first == '"' && last == '"') || (first == '`' && last == '`') { + text = strings.TrimSpace(text[1 : len(text)-1]) + } + } + return text +} + +func quoteDuckDBIdentifier(raw string) string { + text := normalizeDuckDBIdentifier(raw) + return `"` + strings.ReplaceAll(text, `"`, `""`) + `"` +} + +func quoteDuckDBQualifiedTable(schema string, table string) string { + s := strings.TrimSpace(schema) + t := strings.TrimSpace(table) + if s == "" { + return quoteDuckDBIdentifier(t) + } + return quoteDuckDBIdentifier(s) + "." + quoteDuckDBIdentifier(t) +} + +func duckDBRowString(row map[string]interface{}, keys ...string) string { + for _, key := range keys { + for rowKey, value := range row { + if !strings.EqualFold(rowKey, key) || value == nil { + continue + } + return fmt.Sprintf("%v", value) + } + } + return "" +} + +func escapeDuckDBLiteral(raw string) string { + return strings.ReplaceAll(raw, "'", "''") +} diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index dbc6ac8..44b269d 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -77,7 +77,8 @@ func applyMySQLURI(config connection.ConnectionConfig) connection.ConnectionConf if uriText == "" { return config } - if !strings.HasPrefix(strings.ToLower(uriText), "mysql://") { + lowerURI := strings.ToLower(uriText) + if !strings.HasPrefix(lowerURI, "mysql://") { return config } diff --git a/internal/sync/sql_helpers.go b/internal/sync/sql_helpers.go index 838905c..44b8a8b 100644 --- a/internal/sync/sql_helpers.go +++ b/internal/sync/sql_helpers.go @@ -22,7 +22,7 @@ func quoteIdentByType(dbType string, ident string) string { } switch dbType { - case "mysql", "mariadb", "sphinx": + case "mysql", "mariadb", "diros", "sphinx": return "`" + strings.ReplaceAll(ident, "`", "``") + "`" case "sqlserver": escaped := strings.ReplaceAll(ident, "]", "]]") @@ -100,7 +100,7 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original return raw } return s + "." + table - case "mysql", "mariadb", "sphinx": + case "mysql", "mariadb", "diros", "sphinx": s := strings.TrimSpace(schema) if s == "" || table == "" { return table