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 +}