From 450fdfa59e738c72e94f32e995e8f83d4bd0521a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=A3=E6=9D=A1?= <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:42:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(oracle-query):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Oracle=20=E8=A1=A8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=88=86=E9=A1=B5=20SQL=20=E5=85=BC=E5=AE=B9=E9=97=AE=E9=A2=98?= =?UTF-8?q?=20refs=20#196=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 12 ++++----- frontend/src/components/DataViewer.tsx | 9 +++---- frontend/src/utils/sql.ts | 35 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0ca13d7..0a4d5b7 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -10,7 +10,7 @@ import { useStore } from '../store'; import type { ColumnDefinition } from '../types'; import { v4 as uuidv4 } from 'uuid'; import 'react-resizable/css/styles.css'; -import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; @@ -2447,18 +2447,18 @@ const DataGrid: React.FC = ({ return clauses.join(' OR '); }, [pkColumns, tableName]); - const buildCurrentPageSql = useCallback((dbType: string) => { + const buildCurrentPageSql = useCallback((dbType: string) => { if (!tableName || !pagination) return ''; const whereSQL = buildWhereSQL(dbType, filterConditions); - let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; - sql += buildOrderBySQL(dbType, sortInfo, pkColumns); + const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; + const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); const normalizedType = String(dbType || '').trim().toLowerCase(); const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend'); + const offset = (pagination.current - 1) * pagination.pageSize; + let sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, pagination.pageSize, offset); if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); } - const offset = (pagination.current - 1) * pagination.pageSize; - sql += ` LIMIT ${pagination.pageSize} OFFSET ${offset}`; return sql; }, [tableName, pagination, filterConditions, sortInfo, pkColumns]); diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 597523e..46acfe1 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; -import { buildOrderBySQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; @@ -455,7 +455,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { if (pageRowCount > 0) { const tailOffset = Math.max(0, totalRows - (offset + pageRowCount)); if (tailOffset < offset) { - sql = `${baseSql}${reverseOrderSQL} LIMIT ${pageRowCount} OFFSET ${tailOffset}`; + sql = buildPaginatedSelectSQL(dbType, baseSql, reverseOrderSQL, pageRowCount, tailOffset); useClickHouseReversePagination = true; clickHouseReverseLimit = pageRowCount; clickHouseReverseHasMore = currentPage < totalPages; @@ -464,7 +464,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } if (!useClickHouseReversePagination) { // 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。 - sql += ` LIMIT ${size + 1} OFFSET ${offset}`; + sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, size + 1, offset); } } @@ -534,8 +534,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { if (safeSelect) { let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; - fallbackSql += buildOrderBySQL(dbType, sortInfo, pkColumns); - fallbackSql += ` LIMIT ${size + 1} OFFSET ${offset}`; + fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset); executedSql = fallbackSql; resData = await executeDataQuery(fallbackSql, '复杂类型降级重试'); } diff --git a/frontend/src/utils/sql.ts b/frontend/src/utils/sql.ts index f15a3ee..14ad50f 100644 --- a/frontend/src/utils/sql.ts +++ b/frontend/src/utils/sql.ts @@ -134,6 +134,41 @@ export const buildOrderBySQL = ( return ''; }; +export const buildPaginatedSelectSQL = ( + dbType: string, + baseSql: string, + orderBySQL: string, + limit: number, + offset: number, +) => { + const normalizedType = String(dbType || '').trim().toLowerCase(); + const safeLimit = Math.max(0, Math.floor(Number(limit) || 0)); + const safeOffset = Math.max(0, Math.floor(Number(offset) || 0)); + const base = String(baseSql || '').trim(); + const orderBy = String(orderBySQL || ''); + + if (!base || safeLimit <= 0) { + return `${base}${orderBy}`; + } + + switch (normalizedType) { + case 'oracle': { + const orderedSql = `${base}${orderBy}`; + const upperBound = safeOffset + safeLimit; + if (safeOffset <= 0) { + return `SELECT * FROM (${orderedSql}) WHERE ROWNUM <= ${upperBound}`; + } + return `SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (${orderedSql}) "__gonavi_page__" WHERE ROWNUM <= ${upperBound}) WHERE "__gonavi_rn__" > ${safeOffset}`; + } + case 'sqlserver': { + const effectiveOrderBy = orderBy.trim() ? orderBy : ' ORDER BY (SELECT NULL)'; + return `${base}${effectiveOrderBy} OFFSET ${safeOffset} ROWS FETCH NEXT ${safeLimit} ROWS ONLY`; + } + default: + return `${base}${orderBy} LIMIT ${safeLimit} OFFSET ${safeOffset}`; + } +}; + export const parseListValues = (val: string) => { const raw = (val || '').trim(); if (!raw) return []; From e521d2125fa95109ed938486818702dcb919608a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Sun, 8 Mar 2026 18:41:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20feat(datasource):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20DuckDB=20Parquet=20=E6=96=87=E4=BB=B6=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=B9=B6=E4=BC=98=E5=8C=96=E5=BC=B9=E7=AA=97=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一 DuckDB 文件库与 Parquet 文件接入能力 - 补充 URI、文件选择、只读挂载与连接缓存键处理 - 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿 --- README.md | 2 +- README.zh-CN.md | 2 +- frontend/src/components/ConnectionModal.tsx | 81 ++++++++++++++--- frontend/src/components/Sidebar.tsx | 90 ++++++++++++++++-- frontend/src/store.ts | 5 + frontend/src/types.ts | 1 + frontend/src/utils/dataSourceCapabilities.ts | 10 +- frontend/src/utils/duckdb.ts | 18 ++++ frontend/wailsjs/go/models.ts | 2 + internal/app/app.go | 30 +++++- internal/app/app_cache_key_test.go | 31 +++++++ internal/app/methods_file.go | 8 +- internal/connection/types.go | 1 + internal/db/duckdb_impl.go | 96 +++++++++++++++++++- 14 files changed, 342 insertions(+), 35 deletions(-) create mode 100644 frontend/src/utils/duckdb.ts diff --git a/README.md b/README.md index c2ad140..ed45f8d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience | Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing | | Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management | | File-based | SQLite | Optional driver agent | Local DB browsing, editing, export | -| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow | +| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow, Parquet mounting | | Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing | | Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing | | Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6c74566..fb1f33c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,7 +39,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做 | 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 | | 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 | | 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 | -| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 | +| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理、Parquet 文件挂载 | | 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 | | 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 | | 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 | diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 7f9efa3..41730b8 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -3,6 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { looksLikeDuckDBParquetPath, resolveDuckDBMode } from '../utils/duckdb'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; @@ -118,6 +119,9 @@ const ConnectionModal: React.FC<{ const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const [selectingDbFile, setSelectingDbFile] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); + const watchedDuckDBMode = Form.useWatch('duckdbMode', form); + const isDuckDBParquetMode = dbType === 'duckdb' + && resolveDuckDBMode(watchedDuckDBMode, String(form.getFieldValue('host') || '')) === 'parquet'; const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); @@ -245,15 +249,17 @@ const ConnectionModal: React.FC<{ } }; - const resolveDriverUnavailableReason = async (type: string): Promise => { + const resolveDriverUnavailableReason = async (type: string, options?: { allowFetch?: boolean }): Promise => { const normalized = normalizeDriverType(type); if (!normalized || normalized === 'custom') { return ''; } + const allowFetch = options?.allowFetch !== false; let snapshot = driverStatusMap; - if (!snapshot[normalized]) { + if (!snapshot[normalized] && allowFetch) { snapshot = await fetchDriverStatusMap(); setDriverStatusMap(snapshot); + setDriverStatusLoaded(true); } const status = snapshot[normalized]; if (!status || status.connectable) { @@ -533,14 +539,25 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const rawPath = trimmedUri + let rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') .trim(); + let duckdbMode = 'database'; + if (type === 'duckdb') { + const queryIndex = rawPath.indexOf('?'); + const searchText = queryIndex >= 0 ? rawPath.slice(queryIndex + 1) : ''; + if (queryIndex >= 0) { + rawPath = rawPath.slice(0, queryIndex).trim(); + } + duckdbMode = resolveDuckDBMode(new URLSearchParams(searchText).get('mode'), safeDecode(rawPath)); + } if (!rawPath) { return null; } - return { host: normalizeFileDbPath(safeDecode(rawPath)) }; + return type === 'duckdb' + ? { host: normalizeFileDbPath(safeDecode(rawPath)), duckdbMode } + : { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'redis') { @@ -753,7 +770,9 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(dbType)) { return dbType === 'duckdb' - ? 'duckdb:///Users/name/demo.duckdb' + ? (isDuckDBParquetMode + ? 'duckdb:///Users/name/demo.parquet?mode=parquet' + : 'duckdb:///Users/name/demo.duckdb') : 'sqlite:///Users/name/demo.sqlite'; } if (dbType === 'mongodb') { @@ -839,12 +858,20 @@ const ConnectionModal: React.FC<{ const scheme = values.useSSL ? 'rediss' : 'redis'; return `${scheme}://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } - if (isFileDatabaseType(type)) { const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } + if (type === 'duckdb') { + const params = new URLSearchParams(); + const duckdbMode = resolveDuckDBMode(values.duckdbMode, pathText); + if (duckdbMode === 'parquet') { + params.set('mode', 'parquet'); + } + const query = params.toString(); + return `${type}://${encodeURI(pathText)}${query ? `?${query}` : ''}`; + } return `${type}://${encodeURI(pathText)}`; } @@ -1027,7 +1054,15 @@ const ConnectionModal: React.FC<{ const data = res.data || {}; const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); if (selectedPath) { - form.setFieldValue('host', normalizeFileDbPath(selectedPath)); + const normalizedPath = normalizeFileDbPath(selectedPath); + if (dbType === 'duckdb') { + form.setFieldsValue({ + host: normalizedPath, + duckdbMode: looksLikeDuckDBParquetPath(normalizedPath) ? 'parquet' : 'database', + }); + } else { + form.setFieldValue('host', normalizedPath); + } } } else if (res?.message !== 'Cancelled') { message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`); @@ -1086,6 +1121,7 @@ const ConnectionModal: React.FC<{ user: config.user, password: config.password, database: config.database, + duckdbMode: configType === 'duckdb' ? resolveDuckDBMode((config as any).duckdbMode, primaryHost) : 'database', uri: config.uri || '', includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, @@ -1194,7 +1230,7 @@ const ConnectionModal: React.FC<{ const isRedisType = values.type === 'redis'; const newConn = { id: initialValues ? initialValues.id : Date.now().toString(), - name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)), + name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? (resolveDuckDBMode(values.duckdbMode, String(values.host || '')) === 'parquet' ? 'DuckDB Parquet' : 'DuckDB DB') : 'SQLite DB') : (values.type === 'redis' ? 'Redis ' + displayHost : displayHost)), config: config, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined @@ -1514,6 +1550,7 @@ const ConnectionModal: React.FC<{ password: keepPassword ? (mergedValues.password || "") : "", savePassword: savePassword, database: mergedValues.database || "", + duckdbMode: type === 'duckdb' ? resolveDuckDBMode(mergedValues.duckdbMode, primaryHost) : undefined, useSSL: effectiveUseSSL, sslMode: effectiveUseSSL ? sslMode : 'disable', sslCertPath: sslCertPath, @@ -1544,9 +1581,8 @@ const ConnectionModal: React.FC<{ mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", }; }; - const handleTypeSelect = async (type: string) => { - const unavailableReason = await resolveDriverUnavailableReason(type); + const unavailableReason = await resolveDriverUnavailableReason(type, { allowFetch: false }); if (unavailableReason) { const normalized = normalizeDriverType(type); const driverName = driverStatusMap[normalized]?.name || type; @@ -1556,6 +1592,9 @@ const ConnectionModal: React.FC<{ setTypeSelectWarning(null); setDbType(type); form.setFieldsValue({ type: type }); + if (!driverStatusLoaded) { + void refreshDriverStatus(); + } const defaultPort = getDefaultPortByType(type); if (isFileDatabaseType(type)) { @@ -1569,6 +1608,7 @@ const ConnectionModal: React.FC<{ user: '', password: '', database: '', + duckdbMode: type === 'duckdb' ? 'database' : undefined, useSSL: false, sslMode: 'preferred', sslCertPath: '', @@ -1811,15 +1851,29 @@ const ConnectionModal: React.FC<{ ) : ( <> + {dbType === 'duckdb' && ( + + @@ -2330,6 +2384,7 @@ const ConnectionModal: React.FC<{ httpTunnelPort: 8080, timeout: 30, uri: '', + duckdbMode: 'database', mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', @@ -2351,7 +2406,7 @@ const ConnectionModal: React.FC<{ setTestResult(null); setTestErrorLogOpen(false); } - if (changed.uri !== undefined || changed.type !== undefined) { + if (changed.uri !== undefined || changed.type !== undefined || changed.duckdbMode !== undefined) { setUriFeedback(null); } if (changed.useSSL !== undefined) { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3a31be4..9348095 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -36,9 +36,15 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, import { SavedConnection } from '../types'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; const { Search } = Input; +const isForceReadOnlyNode = (node: any): boolean => { + const config = node?.dataRef?.config || node?.dataRef; + return getDataSourceCapabilities(config as any).forceReadOnlyQueryResult; +}; + interface TreeNode { title: string; key: string; @@ -3154,14 +3160,41 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); } }, - { - key: 'run-sql', - label: '运行 SQL 文件...', - icon: , - onClick: () => handleRunSQLFile(node) - } - ]; + ]; } else if (node.type === 'view') { + const forceReadOnlyNode = isForceReadOnlyNode(node); + if (forceReadOnlyNode) { + return [ + { + key: 'open-view', + label: '浏览视图数据', + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'view-definition', + label: '查看视图定义', + icon: , + onClick: () => openViewDefinition(node) + }, + { type: 'divider' }, + { + key: 'new-query', + label: '新建查询', + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: `新建查询`, + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: "" + }); + } + }, + ]; + } return [ { key: 'open-view', @@ -3193,7 +3226,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> type: 'query', connectionId: node.dataRef.id, dbName: node.dataRef.dbName, - query: '' + query: "" }); } }, @@ -3242,6 +3275,45 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }, ]; } else if (node.type === 'table') { + const forceReadOnlyNode = isForceReadOnlyNode(node); + if (forceReadOnlyNode) { + return [ + { + key: 'open-table', + label: '浏览数据', + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'new-query', + label: '新建查询', + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: `新建查询`, + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: "" + }); + } + }, + { type: 'divider' }, + { + key: 'export', + label: '导出表数据', + icon: , + children: [ + { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') }, + { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') }, + { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') }, + { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') }, + { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') }, + ] + } + ]; + } return [ { key: 'new-query', @@ -3254,7 +3326,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> type: 'query', connectionId: node.dataRef.id, dbName: node.dataRef.dbName, - query: '' + query: "" }); } }, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 8d67849..a3d448c 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -9,6 +9,7 @@ import { cloneShortcutOptions, sanitizeShortcutOptions, } from './utils/shortcuts'; +import { resolveDuckDBMode } from './utils/duckdb'; const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 }; const DEFAULT_UI_SCALE = 1.0; @@ -243,6 +244,9 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { const supportsNetworkTunnel = type !== 'sqlite' && type !== 'duckdb'; const useHttpTunnel = supportsNetworkTunnel && (raw.useHttpTunnel === true || raw.UseHTTPTunnel === true); const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel; + const duckdbMode = type === 'duckdb' + ? resolveDuckDBMode(raw.duckdbMode, toTrimmedString(raw.host) || toTrimmedString(raw.database)) + : undefined; const safeConfig: ConnectionConfig & Record = { ...raw, @@ -253,6 +257,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { password: savePassword ? toTrimmedString(raw.password) : '', savePassword, database: toTrimmedString(raw.database), + duckdbMode, useSSL: sslCapable ? !!raw.useSSL : false, sslMode: sslCapable ? sslMode : 'disable', sslCertPath: sslCapable ? toTrimmedString(raw.sslCertPath) : '', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 96ac6da..90e9bc6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,6 +29,7 @@ export interface ConnectionConfig { password?: string; savePassword?: boolean; database?: string; + duckdbMode?: 'database' | 'parquet'; useSSL?: boolean; sslMode?: 'preferred' | 'required' | 'skip-verify' | 'disable'; sslCertPath?: string; diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index 8d30854..8672414 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -1,6 +1,7 @@ import type { ConnectionConfig } from '../types'; +import { resolveDuckDBMode } from './duckdb'; -type ConnectionLike = Pick | null | undefined; +type ConnectionLike = Pick | null | undefined; const normalizeDataSourceToken = (raw: string): string => { const normalized = String(raw || '').trim().toLowerCase(); @@ -65,6 +66,11 @@ const COPY_INSERT_TYPES = new Set([ const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']); const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']); +const isDuckDBParquetConnection = (config: ConnectionLike): boolean => { + return resolveDataSourceType(config) === 'duckdb' + && resolveDuckDBMode(config?.duckdbMode, '') === 'parquet'; +}; + export type DataSourceCapabilities = { type: string; supportsQueryEditor: boolean; @@ -80,7 +86,7 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type), supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_TYPES.has(type), - forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type), + forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type) || isDuckDBParquetConnection(config), }; }; diff --git a/frontend/src/utils/duckdb.ts b/frontend/src/utils/duckdb.ts new file mode 100644 index 0000000..ba59246 --- /dev/null +++ b/frontend/src/utils/duckdb.ts @@ -0,0 +1,18 @@ +export type DuckDBMode = 'database' | 'parquet'; + +export const looksLikeDuckDBParquetPath = (raw: string): boolean => { + const text = String(raw || '').trim().toLowerCase(); + return text.endsWith('.parquet') || text.endsWith('.parq'); +}; + +export const normalizeDuckDBMode = (raw: unknown): DuckDBMode => { + return String(raw || '').trim().toLowerCase() === 'parquet' ? 'parquet' : 'database'; +}; + +export const resolveDuckDBMode = (raw: unknown, path: string): DuckDBMode => { + const text = String(raw || '').trim().toLowerCase(); + if (text === 'parquet' || text === 'database') { + return text; + } + return looksLikeDuckDBParquetPath(path) ? 'parquet' : 'database'; +}; \ No newline at end of file diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2de678a..b688567 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -114,6 +114,7 @@ export namespace connection { password: string; savePassword?: boolean; database: string; + duckdbMode?: string; useSSL?: boolean; sslMode?: string; sslCertPath?: string; @@ -154,6 +155,7 @@ export namespace connection { this.password = source["password"]; this.savePassword = source["savePassword"]; this.database = source["database"]; + this.duckdbMode = source["duckdbMode"]; this.useSSL = source["useSSL"]; this.sslMode = source["sslMode"]; this.sslCertPath = source["sslCertPath"]; diff --git a/internal/app/app.go b/internal/app/app.go index 0709a27..3c07f21 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -112,6 +112,11 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn // DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。 normalized.Host = dsn normalized.Database = "" + if normalized.Type == "duckdb" { + normalized.DuckDBMode = normalizeDuckDBConnectionMode(normalized.DuckDBMode, dsn) + } else { + normalized.DuckDBMode = "" + } normalized.Port = 0 normalized.User = "" normalized.Password = "" @@ -131,6 +136,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn normalized.HTTPTunnel = connection.HTTPTunnelConfig{} } + if normalized.Type != "duckdb" { + normalized.DuckDBMode = "" + } return normalized } @@ -145,6 +153,21 @@ func resolveFileDatabaseDSN(config connection.ConnectionConfig) string { return dsn } +func normalizeDuckDBConnectionMode(raw string, sourcePath string) string { + mode := strings.ToLower(strings.TrimSpace(raw)) + if mode == "parquet" { + return "parquet" + } + if mode == "database" { + return "database" + } + lowerPath := strings.ToLower(strings.TrimSpace(sourcePath)) + if strings.HasSuffix(lowerPath, ".parquet") || strings.HasSuffix(lowerPath, ".parq") { + return "parquet" + } + return "database" +} + // Helper: Generate a unique key for the connection config func getCacheKey(config connection.ConnectionConfig) string { normalized := normalizeCacheKeyConfig(config) @@ -266,7 +289,12 @@ func formatConnSummary(config connection.ConnectionConfig) string { if path == "" { path = "(未配置)" } - b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) + if normalizedType == "duckdb" { + mode := normalizeDuckDBConnectionMode(config.DuckDBMode, path) + b.WriteString(fmt.Sprintf("类型=%s 模式=%s 路径=%s 超时=%ds", config.Type, mode, path, timeoutSeconds)) + } else { + b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) + } } else { b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) diff --git a/internal/app/app_cache_key_test.go b/internal/app/app_cache_key_test.go index ef7714f..f2afc3c 100644 --- a/internal/app/app_cache_key_test.go +++ b/internal/app/app_cache_key_test.go @@ -61,3 +61,34 @@ func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) { t.Fatalf("expected different cache key for different database targets") } } + +func TestGetCacheKey_DuckDBModeAffectsKey(t *testing.T) { + databaseMode := connection.ConnectionConfig{ + Type: "duckdb", + Host: `D:\data\songs.parquet`, + DuckDBMode: "database", + } + parquetMode := databaseMode + parquetMode.DuckDBMode = "parquet" + + left := getCacheKey(databaseMode) + right := getCacheKey(parquetMode) + if left == right { + t.Fatalf("expected different cache key for duckdb file modes") + } +} + +func TestGetCacheKey_DuckDBParquetModeInferenceConsistent(t *testing.T) { + inferred := connection.ConnectionConfig{ + Type: "duckdb", + Host: `D:\data\songs.parquet`, + } + explicit := inferred + explicit.DuckDBMode = "parquet" + + left := getCacheKey(inferred) + right := getCacheKey(explicit) + if left != right { + t.Fatalf("expected same cache key for inferred and explicit parquet mode, got %s vs %s", left, right) + } +} diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 9e5fc1b..e2a8176 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -148,7 +148,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti filters := []runtime.FileFilter{ { DisplayName: "数据库文件", - Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb", + Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb;*.parquet;*.parq", }, { DisplayName: "所有文件", @@ -170,11 +170,11 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti }, } case "duckdb": - title = "选择 DuckDB 数据文件" + title = "选择 DuckDB / Parquet 文件" filters = []runtime.FileFilter{ { - DisplayName: "DuckDB 文件", - Pattern: "*.duckdb;*.ddb;*.db", + DisplayName: "DuckDB / Parquet 文件", + Pattern: "*.duckdb;*.ddb;*.db;*.parquet;*.parq", }, { DisplayName: "所有文件", diff --git a/internal/connection/types.go b/internal/connection/types.go index bac9ec7..f145ec2 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -35,6 +35,7 @@ type ConnectionConfig struct { Password string `json:"password"` SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection Database string `json:"database"` + DuckDBMode string `json:"duckdbMode,omitempty"` UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) diff --git a/internal/db/duckdb_impl.go b/internal/db/duckdb_impl.go index f87ca74..b4cbc63 100644 --- a/internal/db/duckdb_impl.go +++ b/internal/db/duckdb_impl.go @@ -6,8 +6,10 @@ import ( "context" "database/sql" "fmt" + "path/filepath" "strings" "time" + "unicode" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/utils" @@ -16,6 +18,9 @@ import ( type DuckDB struct { conn *sql.DB pingTimeout time.Duration + mode string + sourcePath string + mountedView string } func (d *DuckDB) Connect(config connection.ConnectionConfig) error { @@ -23,11 +28,18 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error { return fmt.Errorf("DuckDB 驱动不可用:%s", reason) } - dsn := strings.TrimSpace(config.Host) - if dsn == "" { - dsn = strings.TrimSpace(config.Database) + sourcePath := strings.TrimSpace(config.Host) + if sourcePath == "" { + sourcePath = strings.TrimSpace(config.Database) } - if dsn == "" { + mode := normalizeDuckDBConnectionMode(config.DuckDBMode, sourcePath) + dsn := sourcePath + if mode == "parquet" { + if strings.TrimSpace(sourcePath) == "" || sourcePath == ":memory:" { + return fmt.Errorf("Parquet 文件模式要求提供 .parquet 或 .parq 文件路径") + } + dsn = ":memory:" + } else if dsn == "" { dsn = ":memory:" } @@ -37,12 +49,22 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error { } d.conn = db d.pingTimeout = getConnectTimeout(config) + d.mode = mode + d.sourcePath = sourcePath + d.mountedView = "" if err := d.Ping(); err != nil { _ = db.Close() d.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } + if mode == "parquet" { + if err := d.mountParquetView(sourcePath); err != nil { + _ = db.Close() + d.conn = nil + return fmt.Errorf("连接建立后挂载 Parquet 失败:%w", err) + } + } return nil } @@ -399,6 +421,26 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er return tx.Commit() } +func (d *DuckDB) mountParquetView(sourcePath string) error { + if d.conn == nil { + return fmt.Errorf("connection not open") + } + viewName := deriveDuckDBParquetViewName(sourcePath) + if viewName == "" { + viewName = "parquet_data" + } + query := fmt.Sprintf( + "CREATE OR REPLACE VIEW %s AS SELECT * FROM read_parquet('%s')", + quoteDuckDBQualifiedTable("main", viewName), + escapeDuckDBLiteral(sourcePath), + ) + if _, err := d.conn.Exec(query); err != nil { + return err + } + d.mountedView = viewName + return nil +} + func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) { schema := strings.TrimSpace(dbName) table := strings.TrimSpace(tableName) @@ -464,3 +506,49 @@ func duckDBRowString(row map[string]interface{}, keys ...string) string { func escapeDuckDBLiteral(raw string) string { return strings.ReplaceAll(raw, "'", "''") } + +func normalizeDuckDBConnectionMode(raw string, sourcePath string) string { + mode := strings.ToLower(strings.TrimSpace(raw)) + if mode == "parquet" { + return "parquet" + } + if mode == "database" { + return "database" + } + lowerPath := strings.ToLower(strings.TrimSpace(sourcePath)) + if strings.HasSuffix(lowerPath, ".parquet") || strings.HasSuffix(lowerPath, ".parq") { + return "parquet" + } + return "database" +} + +func deriveDuckDBParquetViewName(sourcePath string) string { + baseName := strings.TrimSpace(filepath.Base(strings.TrimSpace(sourcePath))) + if ext := filepath.Ext(baseName); ext != "" { + baseName = strings.TrimSuffix(baseName, ext) + } + if baseName == "" { + return "parquet_data" + } + + var builder strings.Builder + for _, r := range baseName { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(unicode.ToLower(r)) + case r == '_': + builder.WriteRune(r) + default: + builder.WriteRune('_') + } + } + + name := strings.Trim(builder.String(), "_") + if name == "" { + name = "parquet_data" + } + if unicode.IsDigit(rune(name[0])) { + name = "parquet_" + name + } + return name +} From b85c7529ecd689659c4e14a836b87b96d3402aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Sun, 8 Mar 2026 18:42:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20feat(datasource):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20DuckDB=20Parquet=20=E6=96=87=E4=BB=B6=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=B9=B6=E4=BC=98=E5=8C=96=E5=BC=B9=E7=AA=97=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一 DuckDB 文件库与 Parquet 文件接入能力 - 补充 URI、文件选择、只读挂载与连接缓存键处理 - 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿 - refs #166 --- README.md | 2 +- README.zh-CN.md | 2 +- frontend/src/components/ConnectionModal.tsx | 81 +++-------------- frontend/src/components/Sidebar.tsx | 90 ++---------------- frontend/src/store.ts | 5 - frontend/src/types.ts | 1 - frontend/src/utils/dataSourceCapabilities.ts | 10 +- frontend/src/utils/duckdb.ts | 18 ---- frontend/wailsjs/go/models.ts | 2 - internal/app/app.go | 30 +----- internal/app/app_cache_key_test.go | 31 ------- internal/app/methods_file.go | 8 +- internal/connection/types.go | 1 - internal/db/duckdb_impl.go | 96 +------------------- 14 files changed, 35 insertions(+), 342 deletions(-) delete mode 100644 frontend/src/utils/duckdb.ts diff --git a/README.md b/README.md index ed45f8d..c2ad140 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience | Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing | | Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management | | File-based | SQLite | Optional driver agent | Local DB browsing, editing, export | -| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow, Parquet mounting | +| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow | | Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing | | Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing | | Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing | diff --git a/README.zh-CN.md b/README.zh-CN.md index fb1f33c..6c74566 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,7 +39,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做 | 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 | | 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 | | 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 | -| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理、Parquet 文件挂载 | +| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 | | 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 | | 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 | | 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 | diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 41730b8..7f9efa3 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -3,7 +3,6 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; -import { looksLikeDuckDBParquetPath, resolveDuckDBMode } from '../utils/duckdb'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; @@ -119,9 +118,6 @@ const ConnectionModal: React.FC<{ const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const [selectingDbFile, setSelectingDbFile] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); - const watchedDuckDBMode = Form.useWatch('duckdbMode', form); - const isDuckDBParquetMode = dbType === 'duckdb' - && resolveDuckDBMode(watchedDuckDBMode, String(form.getFieldValue('host') || '')) === 'parquet'; const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); @@ -249,17 +245,15 @@ const ConnectionModal: React.FC<{ } }; - const resolveDriverUnavailableReason = async (type: string, options?: { allowFetch?: boolean }): Promise => { + const resolveDriverUnavailableReason = async (type: string): Promise => { const normalized = normalizeDriverType(type); if (!normalized || normalized === 'custom') { return ''; } - const allowFetch = options?.allowFetch !== false; let snapshot = driverStatusMap; - if (!snapshot[normalized] && allowFetch) { + if (!snapshot[normalized]) { snapshot = await fetchDriverStatusMap(); setDriverStatusMap(snapshot); - setDriverStatusLoaded(true); } const status = snapshot[normalized]; if (!status || status.connectable) { @@ -539,25 +533,14 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - let rawPath = trimmedUri + const rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') .trim(); - let duckdbMode = 'database'; - if (type === 'duckdb') { - const queryIndex = rawPath.indexOf('?'); - const searchText = queryIndex >= 0 ? rawPath.slice(queryIndex + 1) : ''; - if (queryIndex >= 0) { - rawPath = rawPath.slice(0, queryIndex).trim(); - } - duckdbMode = resolveDuckDBMode(new URLSearchParams(searchText).get('mode'), safeDecode(rawPath)); - } if (!rawPath) { return null; } - return type === 'duckdb' - ? { host: normalizeFileDbPath(safeDecode(rawPath)), duckdbMode } - : { host: normalizeFileDbPath(safeDecode(rawPath)) }; + return { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'redis') { @@ -770,9 +753,7 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(dbType)) { return dbType === 'duckdb' - ? (isDuckDBParquetMode - ? 'duckdb:///Users/name/demo.parquet?mode=parquet' - : 'duckdb:///Users/name/demo.duckdb') + ? 'duckdb:///Users/name/demo.duckdb' : 'sqlite:///Users/name/demo.sqlite'; } if (dbType === 'mongodb') { @@ -858,20 +839,12 @@ const ConnectionModal: React.FC<{ const scheme = values.useSSL ? 'rediss' : 'redis'; return `${scheme}://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } + if (isFileDatabaseType(type)) { const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } - if (type === 'duckdb') { - const params = new URLSearchParams(); - const duckdbMode = resolveDuckDBMode(values.duckdbMode, pathText); - if (duckdbMode === 'parquet') { - params.set('mode', 'parquet'); - } - const query = params.toString(); - return `${type}://${encodeURI(pathText)}${query ? `?${query}` : ''}`; - } return `${type}://${encodeURI(pathText)}`; } @@ -1054,15 +1027,7 @@ const ConnectionModal: React.FC<{ const data = res.data || {}; const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); if (selectedPath) { - const normalizedPath = normalizeFileDbPath(selectedPath); - if (dbType === 'duckdb') { - form.setFieldsValue({ - host: normalizedPath, - duckdbMode: looksLikeDuckDBParquetPath(normalizedPath) ? 'parquet' : 'database', - }); - } else { - form.setFieldValue('host', normalizedPath); - } + form.setFieldValue('host', normalizeFileDbPath(selectedPath)); } } else if (res?.message !== 'Cancelled') { message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`); @@ -1121,7 +1086,6 @@ const ConnectionModal: React.FC<{ user: config.user, password: config.password, database: config.database, - duckdbMode: configType === 'duckdb' ? resolveDuckDBMode((config as any).duckdbMode, primaryHost) : 'database', uri: config.uri || '', includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, @@ -1230,7 +1194,7 @@ const ConnectionModal: React.FC<{ const isRedisType = values.type === 'redis'; const newConn = { id: initialValues ? initialValues.id : Date.now().toString(), - name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? (resolveDuckDBMode(values.duckdbMode, String(values.host || '')) === 'parquet' ? 'DuckDB Parquet' : 'DuckDB DB') : '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 @@ -1550,7 +1514,6 @@ const ConnectionModal: React.FC<{ password: keepPassword ? (mergedValues.password || "") : "", savePassword: savePassword, database: mergedValues.database || "", - duckdbMode: type === 'duckdb' ? resolveDuckDBMode(mergedValues.duckdbMode, primaryHost) : undefined, useSSL: effectiveUseSSL, sslMode: effectiveUseSSL ? sslMode : 'disable', sslCertPath: sslCertPath, @@ -1581,8 +1544,9 @@ const ConnectionModal: React.FC<{ mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", }; }; + const handleTypeSelect = async (type: string) => { - const unavailableReason = await resolveDriverUnavailableReason(type, { allowFetch: false }); + const unavailableReason = await resolveDriverUnavailableReason(type); if (unavailableReason) { const normalized = normalizeDriverType(type); const driverName = driverStatusMap[normalized]?.name || type; @@ -1592,9 +1556,6 @@ const ConnectionModal: React.FC<{ setTypeSelectWarning(null); setDbType(type); form.setFieldsValue({ type: type }); - if (!driverStatusLoaded) { - void refreshDriverStatus(); - } const defaultPort = getDefaultPortByType(type); if (isFileDatabaseType(type)) { @@ -1608,7 +1569,6 @@ const ConnectionModal: React.FC<{ user: '', password: '', database: '', - duckdbMode: type === 'duckdb' ? 'database' : undefined, useSSL: false, sslMode: 'preferred', sslCertPath: '', @@ -1851,29 +1811,15 @@ const ConnectionModal: React.FC<{ ) : ( <> - {dbType === 'duckdb' && ( - - @@ -2384,7 +2330,6 @@ const ConnectionModal: React.FC<{ httpTunnelPort: 8080, timeout: 30, uri: '', - duckdbMode: 'database', mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', @@ -2406,7 +2351,7 @@ const ConnectionModal: React.FC<{ setTestResult(null); setTestErrorLogOpen(false); } - if (changed.uri !== undefined || changed.type !== undefined || changed.duckdbMode !== undefined) { + if (changed.uri !== undefined || changed.type !== undefined) { setUriFeedback(null); } if (changed.useSSL !== undefined) { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9348095..3a31be4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -36,15 +36,9 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, import { SavedConnection } from '../types'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; -import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; const { Search } = Input; -const isForceReadOnlyNode = (node: any): boolean => { - const config = node?.dataRef?.config || node?.dataRef; - return getDataSourceCapabilities(config as any).forceReadOnlyQueryResult; -}; - interface TreeNode { title: string; key: string; @@ -3160,41 +3154,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); } }, - ]; + { + key: 'run-sql', + label: '运行 SQL 文件...', + icon: , + onClick: () => handleRunSQLFile(node) + } + ]; } else if (node.type === 'view') { - const forceReadOnlyNode = isForceReadOnlyNode(node); - if (forceReadOnlyNode) { - return [ - { - key: 'open-view', - label: '浏览视图数据', - icon: , - onClick: () => onDoubleClick(null, node) - }, - { - key: 'view-definition', - label: '查看视图定义', - icon: , - onClick: () => openViewDefinition(node) - }, - { type: 'divider' }, - { - key: 'new-query', - label: '新建查询', - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: `新建查询`, - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: "" - }); - } - }, - ]; - } return [ { key: 'open-view', @@ -3226,7 +3193,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> type: 'query', connectionId: node.dataRef.id, dbName: node.dataRef.dbName, - query: "" + query: '' }); } }, @@ -3275,45 +3242,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }, ]; } else if (node.type === 'table') { - const forceReadOnlyNode = isForceReadOnlyNode(node); - if (forceReadOnlyNode) { - return [ - { - key: 'open-table', - label: '浏览数据', - icon: , - onClick: () => onDoubleClick(null, node) - }, - { - key: 'new-query', - label: '新建查询', - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: `新建查询`, - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: "" - }); - } - }, - { type: 'divider' }, - { - key: 'export', - label: '导出表数据', - icon: , - children: [ - { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') }, - { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') }, - { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') }, - { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') }, - { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') }, - ] - } - ]; - } return [ { key: 'new-query', @@ -3326,7 +3254,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> type: 'query', connectionId: node.dataRef.id, dbName: node.dataRef.dbName, - query: "" + query: '' }); } }, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index a3d448c..8d67849 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -9,7 +9,6 @@ import { cloneShortcutOptions, sanitizeShortcutOptions, } from './utils/shortcuts'; -import { resolveDuckDBMode } from './utils/duckdb'; const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 }; const DEFAULT_UI_SCALE = 1.0; @@ -244,9 +243,6 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { const supportsNetworkTunnel = type !== 'sqlite' && type !== 'duckdb'; const useHttpTunnel = supportsNetworkTunnel && (raw.useHttpTunnel === true || raw.UseHTTPTunnel === true); const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel; - const duckdbMode = type === 'duckdb' - ? resolveDuckDBMode(raw.duckdbMode, toTrimmedString(raw.host) || toTrimmedString(raw.database)) - : undefined; const safeConfig: ConnectionConfig & Record = { ...raw, @@ -257,7 +253,6 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { password: savePassword ? toTrimmedString(raw.password) : '', savePassword, database: toTrimmedString(raw.database), - duckdbMode, useSSL: sslCapable ? !!raw.useSSL : false, sslMode: sslCapable ? sslMode : 'disable', sslCertPath: sslCapable ? toTrimmedString(raw.sslCertPath) : '', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 90e9bc6..96ac6da 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,7 +29,6 @@ export interface ConnectionConfig { password?: string; savePassword?: boolean; database?: string; - duckdbMode?: 'database' | 'parquet'; useSSL?: boolean; sslMode?: 'preferred' | 'required' | 'skip-verify' | 'disable'; sslCertPath?: string; diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index 8672414..8d30854 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -1,7 +1,6 @@ import type { ConnectionConfig } from '../types'; -import { resolveDuckDBMode } from './duckdb'; -type ConnectionLike = Pick | null | undefined; +type ConnectionLike = Pick | null | undefined; const normalizeDataSourceToken = (raw: string): string => { const normalized = String(raw || '').trim().toLowerCase(); @@ -66,11 +65,6 @@ const COPY_INSERT_TYPES = new Set([ const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']); const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']); -const isDuckDBParquetConnection = (config: ConnectionLike): boolean => { - return resolveDataSourceType(config) === 'duckdb' - && resolveDuckDBMode(config?.duckdbMode, '') === 'parquet'; -}; - export type DataSourceCapabilities = { type: string; supportsQueryEditor: boolean; @@ -86,7 +80,7 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type), supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_TYPES.has(type), - forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type) || isDuckDBParquetConnection(config), + forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type), }; }; diff --git a/frontend/src/utils/duckdb.ts b/frontend/src/utils/duckdb.ts deleted file mode 100644 index ba59246..0000000 --- a/frontend/src/utils/duckdb.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type DuckDBMode = 'database' | 'parquet'; - -export const looksLikeDuckDBParquetPath = (raw: string): boolean => { - const text = String(raw || '').trim().toLowerCase(); - return text.endsWith('.parquet') || text.endsWith('.parq'); -}; - -export const normalizeDuckDBMode = (raw: unknown): DuckDBMode => { - return String(raw || '').trim().toLowerCase() === 'parquet' ? 'parquet' : 'database'; -}; - -export const resolveDuckDBMode = (raw: unknown, path: string): DuckDBMode => { - const text = String(raw || '').trim().toLowerCase(); - if (text === 'parquet' || text === 'database') { - return text; - } - return looksLikeDuckDBParquetPath(path) ? 'parquet' : 'database'; -}; \ No newline at end of file diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b688567..2de678a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -114,7 +114,6 @@ export namespace connection { password: string; savePassword?: boolean; database: string; - duckdbMode?: string; useSSL?: boolean; sslMode?: string; sslCertPath?: string; @@ -155,7 +154,6 @@ export namespace connection { this.password = source["password"]; this.savePassword = source["savePassword"]; this.database = source["database"]; - this.duckdbMode = source["duckdbMode"]; this.useSSL = source["useSSL"]; this.sslMode = source["sslMode"]; this.sslCertPath = source["sslCertPath"]; diff --git a/internal/app/app.go b/internal/app/app.go index 3c07f21..0709a27 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -112,11 +112,6 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn // DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。 normalized.Host = dsn normalized.Database = "" - if normalized.Type == "duckdb" { - normalized.DuckDBMode = normalizeDuckDBConnectionMode(normalized.DuckDBMode, dsn) - } else { - normalized.DuckDBMode = "" - } normalized.Port = 0 normalized.User = "" normalized.Password = "" @@ -136,9 +131,6 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn normalized.HTTPTunnel = connection.HTTPTunnelConfig{} } - if normalized.Type != "duckdb" { - normalized.DuckDBMode = "" - } return normalized } @@ -153,21 +145,6 @@ func resolveFileDatabaseDSN(config connection.ConnectionConfig) string { return dsn } -func normalizeDuckDBConnectionMode(raw string, sourcePath string) string { - mode := strings.ToLower(strings.TrimSpace(raw)) - if mode == "parquet" { - return "parquet" - } - if mode == "database" { - return "database" - } - lowerPath := strings.ToLower(strings.TrimSpace(sourcePath)) - if strings.HasSuffix(lowerPath, ".parquet") || strings.HasSuffix(lowerPath, ".parq") { - return "parquet" - } - return "database" -} - // Helper: Generate a unique key for the connection config func getCacheKey(config connection.ConnectionConfig) string { normalized := normalizeCacheKeyConfig(config) @@ -289,12 +266,7 @@ func formatConnSummary(config connection.ConnectionConfig) string { if path == "" { path = "(未配置)" } - if normalizedType == "duckdb" { - mode := normalizeDuckDBConnectionMode(config.DuckDBMode, path) - b.WriteString(fmt.Sprintf("类型=%s 模式=%s 路径=%s 超时=%ds", config.Type, mode, path, timeoutSeconds)) - } else { - b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) - } + b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) } else { b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) diff --git a/internal/app/app_cache_key_test.go b/internal/app/app_cache_key_test.go index f2afc3c..ef7714f 100644 --- a/internal/app/app_cache_key_test.go +++ b/internal/app/app_cache_key_test.go @@ -61,34 +61,3 @@ func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) { t.Fatalf("expected different cache key for different database targets") } } - -func TestGetCacheKey_DuckDBModeAffectsKey(t *testing.T) { - databaseMode := connection.ConnectionConfig{ - Type: "duckdb", - Host: `D:\data\songs.parquet`, - DuckDBMode: "database", - } - parquetMode := databaseMode - parquetMode.DuckDBMode = "parquet" - - left := getCacheKey(databaseMode) - right := getCacheKey(parquetMode) - if left == right { - t.Fatalf("expected different cache key for duckdb file modes") - } -} - -func TestGetCacheKey_DuckDBParquetModeInferenceConsistent(t *testing.T) { - inferred := connection.ConnectionConfig{ - Type: "duckdb", - Host: `D:\data\songs.parquet`, - } - explicit := inferred - explicit.DuckDBMode = "parquet" - - left := getCacheKey(inferred) - right := getCacheKey(explicit) - if left != right { - t.Fatalf("expected same cache key for inferred and explicit parquet mode, got %s vs %s", left, right) - } -} diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index e2a8176..9e5fc1b 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -148,7 +148,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti filters := []runtime.FileFilter{ { DisplayName: "数据库文件", - Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb;*.parquet;*.parq", + Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb", }, { DisplayName: "所有文件", @@ -170,11 +170,11 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti }, } case "duckdb": - title = "选择 DuckDB / Parquet 文件" + title = "选择 DuckDB 数据文件" filters = []runtime.FileFilter{ { - DisplayName: "DuckDB / Parquet 文件", - Pattern: "*.duckdb;*.ddb;*.db;*.parquet;*.parq", + DisplayName: "DuckDB 文件", + Pattern: "*.duckdb;*.ddb;*.db", }, { DisplayName: "所有文件", diff --git a/internal/connection/types.go b/internal/connection/types.go index f145ec2..bac9ec7 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -35,7 +35,6 @@ type ConnectionConfig struct { Password string `json:"password"` SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection Database string `json:"database"` - DuckDBMode string `json:"duckdbMode,omitempty"` UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) diff --git a/internal/db/duckdb_impl.go b/internal/db/duckdb_impl.go index b4cbc63..f87ca74 100644 --- a/internal/db/duckdb_impl.go +++ b/internal/db/duckdb_impl.go @@ -6,10 +6,8 @@ import ( "context" "database/sql" "fmt" - "path/filepath" "strings" "time" - "unicode" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/utils" @@ -18,9 +16,6 @@ import ( type DuckDB struct { conn *sql.DB pingTimeout time.Duration - mode string - sourcePath string - mountedView string } func (d *DuckDB) Connect(config connection.ConnectionConfig) error { @@ -28,18 +23,11 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error { return fmt.Errorf("DuckDB 驱动不可用:%s", reason) } - sourcePath := strings.TrimSpace(config.Host) - if sourcePath == "" { - sourcePath = strings.TrimSpace(config.Database) + dsn := strings.TrimSpace(config.Host) + if dsn == "" { + dsn = strings.TrimSpace(config.Database) } - mode := normalizeDuckDBConnectionMode(config.DuckDBMode, sourcePath) - dsn := sourcePath - if mode == "parquet" { - if strings.TrimSpace(sourcePath) == "" || sourcePath == ":memory:" { - return fmt.Errorf("Parquet 文件模式要求提供 .parquet 或 .parq 文件路径") - } - dsn = ":memory:" - } else if dsn == "" { + if dsn == "" { dsn = ":memory:" } @@ -49,22 +37,12 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error { } d.conn = db d.pingTimeout = getConnectTimeout(config) - d.mode = mode - d.sourcePath = sourcePath - d.mountedView = "" if err := d.Ping(); err != nil { _ = db.Close() d.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } - if mode == "parquet" { - if err := d.mountParquetView(sourcePath); err != nil { - _ = db.Close() - d.conn = nil - return fmt.Errorf("连接建立后挂载 Parquet 失败:%w", err) - } - } return nil } @@ -421,26 +399,6 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er return tx.Commit() } -func (d *DuckDB) mountParquetView(sourcePath string) error { - if d.conn == nil { - return fmt.Errorf("connection not open") - } - viewName := deriveDuckDBParquetViewName(sourcePath) - if viewName == "" { - viewName = "parquet_data" - } - query := fmt.Sprintf( - "CREATE OR REPLACE VIEW %s AS SELECT * FROM read_parquet('%s')", - quoteDuckDBQualifiedTable("main", viewName), - escapeDuckDBLiteral(sourcePath), - ) - if _, err := d.conn.Exec(query); err != nil { - return err - } - d.mountedView = viewName - return nil -} - func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) { schema := strings.TrimSpace(dbName) table := strings.TrimSpace(tableName) @@ -506,49 +464,3 @@ func duckDBRowString(row map[string]interface{}, keys ...string) string { func escapeDuckDBLiteral(raw string) string { return strings.ReplaceAll(raw, "'", "''") } - -func normalizeDuckDBConnectionMode(raw string, sourcePath string) string { - mode := strings.ToLower(strings.TrimSpace(raw)) - if mode == "parquet" { - return "parquet" - } - if mode == "database" { - return "database" - } - lowerPath := strings.ToLower(strings.TrimSpace(sourcePath)) - if strings.HasSuffix(lowerPath, ".parquet") || strings.HasSuffix(lowerPath, ".parq") { - return "parquet" - } - return "database" -} - -func deriveDuckDBParquetViewName(sourcePath string) string { - baseName := strings.TrimSpace(filepath.Base(strings.TrimSpace(sourcePath))) - if ext := filepath.Ext(baseName); ext != "" { - baseName = strings.TrimSuffix(baseName, ext) - } - if baseName == "" { - return "parquet_data" - } - - var builder strings.Builder - for _, r := range baseName { - switch { - case unicode.IsLetter(r), unicode.IsDigit(r): - builder.WriteRune(unicode.ToLower(r)) - case r == '_': - builder.WriteRune(r) - default: - builder.WriteRune('_') - } - } - - name := strings.Trim(builder.String(), "_") - if name == "" { - name = "parquet_data" - } - if unicode.IsDigit(rune(name[0])) { - name = "parquet_" + name - } - return name -}