mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 04:52:44 +08:00
✨ feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路
- 统一 DuckDB 文件库与 Parquet 文件接入能力 - 补充 URI、文件选择、只读挂载与连接缓存键处理 - 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿 - refs #166
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -39,7 +39,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
|
||||
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理、Parquet 文件挂载 |
|
||||
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
|
||||
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: ''
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) : '',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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"];
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "所有文件",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user