mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-10 17:43:15 +08:00
🐛 fix(driver): 修复驱动代理校验与 DuckDB 表预览超时
- 校验可选 driver-agent revision,避免重装后复用旧代理 - DuckDB 表预览默认不再追加兜底 ORDER BY - 优化 DuckDB 超时中断提示并补充回归测试
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
13
frontend/src/utils/sql.test.ts
Normal file
13
frontend/src/utils/sql.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user