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

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