feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166
This commit is contained in:
杨国锋
2026-03-08 18:42:27 +08:00
parent e521d2125f
commit b85c7529ec
14 changed files with 35 additions and 342 deletions

View File

@@ -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 |

View File

@@ -39,7 +39,7 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理、Parquet 文件挂载 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |

View File

@@ -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<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
@@ -249,17 +245,15 @@ const ConnectionModal: React.FC<{
}
};
const resolveDriverUnavailableReason = async (type: string, options?: { allowFetch?: boolean }): Promise<string> => {
const resolveDriverUnavailableReason = async (type: string): Promise<string> => {
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' && (
<Form.Item
name="duckdbMode"
label="文件模式"
help={isDuckDBParquetMode ? 'Parquet 会以只读视图挂载到 DuckDB适合浏览与查询。' : '数据库文件模式保持 DuckDB 原生文件库行为。'}
>
<Select
options={[
{ value: 'database', label: '数据库文件' },
{ value: 'parquet', label: 'Parquet 文件' },
]}
/>
</Form.Item>
)}
<div style={{ display: 'grid', gridTemplateColumns: isFileDb ? 'minmax(0, 1fr) 120px' : 'minmax(0, 1fr) 120px', gap: 16, alignItems: 'start' }}>
<Form.Item
name="host"
label={isFileDb ? (isDuckDBParquetMode ? 'Parquet 文件路径 (绝对路径)' : '文件路径 (绝对路径)') : '主机地址 (Host)'}
label={isFileDb ? '文件路径 (绝对路径)' : '主机地址 (Host)'}
rules={[createUriAwareRequiredRule('请输入地址/路径')]}
style={{ marginBottom: 0 }}
>
<Input
placeholder={isFileDb ? (dbType === 'duckdb' ? (isDuckDBParquetMode ? '/path/to/data.parquet' : '/path/to/db.duckdb') : '/path/to/db.sqlite') : 'localhost'}
placeholder={isFileDb ? (dbType === 'duckdb' ? '/path/to/db.duckdb' : '/path/to/db.sqlite') : 'localhost'}
onDoubleClick={requestTest}
/>
</Form.Item>
@@ -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) {

View File

@@ -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: <FileAddOutlined />,
onClick: () => handleRunSQLFile(node)
}
];
} else if (node.type === 'view') {
const forceReadOnlyNode = isForceReadOnlyNode(node);
if (forceReadOnlyNode) {
return [
{
key: 'open-view',
label: '浏览视图数据',
icon: <EyeOutlined />,
onClick: () => onDoubleClick(null, node)
},
{
key: 'view-definition',
label: '查看视图定义',
icon: <CodeOutlined />,
onClick: () => openViewDefinition(node)
},
{ type: 'divider' },
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
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: <EyeOutlined />,
onClick: () => onDoubleClick(null, node)
},
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: ""
});
}
},
{ type: 'divider' },
{
key: 'export',
label: '导出表数据',
icon: <ExportOutlined />,
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: ''
});
}
},

View File

@@ -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<string, unknown> = {
...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) : '',

View File

@@ -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;

View File

@@ -1,7 +1,6 @@
import type { ConnectionConfig } from '../types';
import { resolveDuckDBMode } from './duckdb';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'duckdbMode'> | null | undefined;
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | 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),
};
};

View File

@@ -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';
};

View File

@@ -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"];

View File

@@ -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))

View File

@@ -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)
}
}

View File

@@ -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: "所有文件",

View File

@@ -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)

View File

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