🐛 fix(driver): 修复驱动代理校验与 DuckDB 表预览超时

- 校验可选 driver-agent revision,避免重装后复用旧代理
- DuckDB 表预览默认不再追加兜底 ORDER BY
- 优化 DuckDB 超时中断提示并补充回归测试
This commit is contained in:
Syngnat
2026-05-06 19:32:55 +08:00
parent 3c68325132
commit da9a76715a
6 changed files with 229 additions and 9 deletions

View File

@@ -176,6 +176,46 @@ describe('DataViewer safe editing locator', () => {
renderer.unmount();
});
it('does not add fallback ORDER BY for DuckDB table preview when a primary key is available', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-order', dbName: 'main', tableName: 'events', title: 'events' }));
const tableQueries = backendApp.DBQuery.mock.calls
.map((call: any[]) => String(call[2] || ''))
.filter((sql: string) => sql.includes('FROM "events"'));
expect(tableQueries.length).toBeGreaterThan(0);
expect(tableQueries.every((sql: string) => !/\border\s+by\b/i.test(sql))).toBe(true);
expect(tableQueries[tableQueries.length - 1]).toContain('LIMIT 101 OFFSET 0');
renderer.unmount();
});
it('shows an actionable message for DuckDB timeout interruption errors', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: false,
message: 'context deadline exceeded INTERRUPT Error: Interrupted!',
fields: [],
data: [],
});
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-timeout', dbName: 'main', tableName: 'events', title: 'events' }));
expect(messageApi.error).toHaveBeenCalledWith('DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。');
expect(storeState.addSqlLog.mock.calls.some((call: any[]) => String(call[0]?.message || '').includes('context deadline exceeded'))).toBe(true);
renderer.unmount();
});
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';

View File

@@ -165,6 +165,20 @@ const isDuckDBComplexColumnType = (columnType?: string): boolean => {
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
};
const formatDataViewerQueryError = (dbType: string, messageText: unknown): string => {
const rawMessage = String(messageText || '查询失败').trim() || '查询失败';
const lower = rawMessage.toLowerCase();
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('超时');
const isDuckDBInterrupted = String(dbType || '').trim().toLowerCase() === 'duckdb' && (lower.includes('interrupt error') || lower.includes('interrupted'));
if (isTimeout || isDuckDBInterrupted) {
if (String(dbType || '').trim().toLowerCase() === 'duckdb') {
return 'DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。';
}
return '查询超过连接超时时间,已中断。请调大连接超时时间,或减少查询范围后重试。';
}
return rawMessage;
};
const reverseOrderBySQL = (orderBySQL: string): string => {
const raw = String(orderBySQL || '').trim();
if (!raw) return '';
@@ -929,11 +943,11 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
}
} else {
message.error(String(resData.message || '查询失败'));
message.error(formatDataViewerQueryError(dbTypeLower, resData.message));
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
message.error("Error fetching data: " + e.message);
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e));
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { buildOrderBySQL } from './sql';
describe('buildOrderBySQL', () => {
it('does not add fallback ORDER BY for DuckDB without explicit sort', () => {
expect(buildOrderBySQL('duckdb', [], ['ID'])).toBe('');
});
it('keeps explicit DuckDB sort', () => {
expect(buildOrderBySQL('duckdb', { columnKey: 'ID', order: 'descend' }, ['NAME'])).toBe(' ORDER BY "ID" DESC');
});
});

View File

@@ -150,10 +150,10 @@ export const buildOrderBySQL = (
return ` ORDER BY ${sortParts.join(', ')}`;
}
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY即使按主键可能触发 filesort
// 导致 `Error 1038 (HY001): Out of sort memory`
// 部分数据源在无显式排序需求时强制 ORDER BY即使按主键会显著放大大表预览成本:
// MySQL/MariaDB 可能触发 filesort 和 sort memory 错误DuckDB 大文件可能被排序拖到连接超时
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'duckdb') {
return '';
}

View File

@@ -39,8 +39,11 @@ var (
goBinaryCommandOutput = func(cmd *exec.Cmd) ([]byte, error) {
return cmd.Output()
}
optionalDriverAgentMetadataProbe = db.ProbeOptionalDriverAgentMetadata
)
var errOptionalDriverAgentMetadataUnavailable = errors.New("driver-agent metadata unavailable")
// resolveGoBinaryPath 定位 Go 可执行文件,兼容 macOS 图形应用未继承 shell PATH 的场景 by AI.Coding
func resolveGoBinaryPath() (string, error) {
if goPath, err := goBinaryLookPath("go"); err == nil {
@@ -2752,6 +2755,55 @@ func optionalDriverAgentRevisionStatus(driverType string, pkg installedDriverPac
return true, fmt.Sprintf("原因:%s。影响%s已安装标记%s当前需要%s", updateReason, impact, actual, expected), expected
}
func optionalDriverAgentRevisionCurrent(driverType string, executablePath string) (string, bool, error) {
expected := strings.TrimSpace(db.OptionalDriverAgentRevision(driverType))
if expected == "" {
return "", true, nil
}
metadata, err := optionalDriverAgentMetadataProbe(driverType, executablePath)
if err != nil {
return "", false, fmt.Errorf("%w: %v", errOptionalDriverAgentMetadataUnavailable, err)
}
actual := strings.TrimSpace(metadata.AgentRevision)
return actual, actual == expected, nil
}
func verifyInstalledOptionalDriverAgentRevision(driverType string, executablePath string, selectedVersion ...string) (string, error) {
version := ""
if len(selectedVersion) > 0 {
version = selectedVersion[0]
}
if !shouldVerifyOptionalDriverAgentRevision(driverType, version) {
return "", nil
}
expected := strings.TrimSpace(db.OptionalDriverAgentRevision(driverType))
actual, current, err := optionalDriverAgentRevisionCurrent(driverType, executablePath)
if expected == "" {
return actual, nil
}
displayName := resolveDriverDisplayName(driverDefinition{Type: driverType})
if err != nil {
return "", fmt.Errorf("%s 驱动代理版本元数据不可用,请安装当前版本对应的 driver-agent%w", displayName, err)
}
if !current {
actualLabel := strings.TrimSpace(actual)
if actualLabel == "" {
actualLabel = "空"
}
return "", fmt.Errorf("%s 驱动代理 revision 不匹配(已安装:%s当前需要%s请安装当前版本对应的 driver-agent", displayName, actualLabel, expected)
}
return actual, nil
}
func shouldVerifyOptionalDriverAgentRevision(driverType string, selectedVersion string) bool {
switch normalizeDriverType(driverType) {
case "mongodb":
return resolveMongoDriverMajorFromVersion(selectedVersion) != 1
default:
return true
}
}
func (a *App) savedConnectionDriverUsageCounts() map[string]int {
counts := map[string]int{}
if a == nil || strings.TrimSpace(a.configDir) == "" {
@@ -2838,7 +2890,10 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele
if strings.TrimSpace(downloadSource) == "" {
downloadSource = strings.TrimSpace(downloadURL)
}
agentRevision := probeInstalledOptionalDriverAgentRevision(driverType, runtimePath)
agentRevision, revisionErr := verifyInstalledOptionalDriverAgentRevision(driverType, runtimePath, selectedVersion)
if revisionErr != nil {
return installedDriverPackage{}, revisionErr
}
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
@@ -2908,11 +2963,14 @@ func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePa
return installedDriverPackage{}, validateErr
}
agentRevision, revisionErr := verifyInstalledOptionalDriverAgentRevision(driverType, executablePath, selectedVersion)
if revisionErr != nil {
return installedDriverPackage{}, revisionErr
}
hash, hashErr := hashFileSHA256(executablePath)
if hashErr != nil {
return installedDriverPackage{}, fmt.Errorf("计算 %s 驱动代理摘要失败:%w", displayName, hashErr)
}
agentRevision := probeInstalledOptionalDriverAgentRevision(driverType, executablePath)
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
@@ -2931,12 +2989,12 @@ func probeInstalledOptionalDriverAgentRevision(driverType string, executablePath
if strings.TrimSpace(expectedRevision) == "" {
return ""
}
metadata, err := db.ProbeOptionalDriverAgentMetadata(driverType, executablePath)
actualRevision, _, err := optionalDriverAgentRevisionCurrent(driverType, executablePath)
if err != nil {
logger.Warnf("%s 驱动代理未返回版本元数据:%v", resolveDriverDisplayName(driverDefinition{Type: driverType}), err)
return ""
}
return strings.TrimSpace(metadata.AgentRevision)
return strings.TrimSpace(actualRevision)
}
type localDriverCandidate struct {
@@ -3967,11 +4025,32 @@ func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targe
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, absPath); validateErr != nil {
continue
}
if !isReusableOptionalDriverAgentRevisionCurrent(driverType, absPath) {
continue
}
return absPath, true
}
return "", false
}
func isReusableOptionalDriverAgentRevisionCurrent(driverType string, executablePath string) bool {
expected := strings.TrimSpace(db.OptionalDriverAgentRevision(driverType))
if expected == "" {
return true
}
actual, current, err := optionalDriverAgentRevisionCurrent(driverType, executablePath)
displayName := resolveDriverDisplayName(driverDefinition{Type: driverType})
if err != nil {
logger.Warnf("跳过可复用 %s 驱动代理候选:版本元数据不可用 path=%s err=%v", displayName, executablePath, err)
return false
}
if !current {
logger.Warnf("跳过可复用 %s 驱动代理候选revision 不匹配 path=%s actual=%s expected=%s", displayName, executablePath, strings.TrimSpace(actual), expected)
return false
}
return true
}
func resolveOptionalDriverAgentCandidatePaths(definition driverDefinition) []string {
driverType := normalizeDriverType(definition.Type)
names := optionalDriverExecutableBaseNames(driverType)

View File

@@ -5,6 +5,7 @@ import (
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
func TestOptionalDriverAgentRevisionStatusDetectsStaleClickHouseAgent(t *testing.T) {
@@ -32,6 +33,79 @@ func TestOptionalDriverAgentRevisionStatusDetectsStaleClickHouseAgent(t *testing
}
}
func TestOptionalDriverAgentRevisionCurrentRejectsStaleMetadata(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
t.Cleanup(func() {
optionalDriverAgentMetadataProbe = originalProbe
})
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
return db.OptionalDriverAgentMetadata{
DriverType: driverType,
AgentRevision: "src-stale-agent",
}, nil
}
for _, driverType := range optionalDriverAgentRevisionTestDrivers(t) {
t.Run(driverType, func(t *testing.T) {
actual, current, err := optionalDriverAgentRevisionCurrent(driverType, "fake-driver-agent")
if err != nil {
t.Fatalf("expected stale metadata to be comparable, got error: %v", err)
}
if current {
t.Fatalf("expected stale %s agent revision to be rejected", driverType)
}
if actual != "src-stale-agent" {
t.Fatalf("unexpected actual revision: %q", actual)
}
})
}
}
func TestVerifyInstalledOptionalDriverAgentRevisionRejectsProbeFailure(t *testing.T) {
originalProbe := optionalDriverAgentMetadataProbe
t.Cleanup(func() {
optionalDriverAgentMetadataProbe = originalProbe
})
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
return db.OptionalDriverAgentMetadata{}, errOptionalDriverAgentMetadataUnavailable
}
for _, driverType := range optionalDriverAgentRevisionTestDrivers(t) {
t.Run(driverType, func(t *testing.T) {
if _, err := verifyInstalledOptionalDriverAgentRevision(driverType, "fake-driver-agent"); err == nil {
t.Fatalf("expected %s install verification to fail when metadata probe fails", driverType)
}
})
}
}
func optionalDriverAgentRevisionTestDrivers(t *testing.T) []string {
t.Helper()
drivers := []string{
"mariadb",
"oceanbase",
"diros",
"sphinx",
"sqlserver",
"sqlite",
"duckdb",
"dameng",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"mongodb",
"tdengine",
"clickhouse",
}
for _, driverType := range drivers {
if db.OptionalDriverAgentRevision(driverType) == "" {
t.Fatalf("expected %s to define an agent revision", driverType)
}
}
return drivers
}
func TestSavedConnectionDriverUsageCountsIncludesOptionalAndCustomDrivers(t *testing.T) {
app := &App{configDir: t.TempDir()}
repo := app.savedConnectionRepository()