mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 05:32:54 +08:00
* feat: SQL执行中时,增加取消执行功能 (#172)
Co-authored-by: liujie <469282686@qq.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import { DBQuery, DBQueryWithCancel, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
@@ -30,7 +31,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [activeResultKey, setActiveResultKey] = useState<string>('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentQueryId, setCurrentQueryId] = useState<string>('');
|
||||
const runSeqRef = useRef(0);
|
||||
const currentQueryIdRef = useRef('');
|
||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||
const [saveForm] = Form.useForm();
|
||||
|
||||
@@ -186,6 +189,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
fetchMetadata();
|
||||
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
|
||||
// Query ID management helpers
|
||||
const setQueryId = (id: string) => {
|
||||
currentQueryIdRef.current = id;
|
||||
setCurrentQueryId(id);
|
||||
};
|
||||
|
||||
const clearQueryId = () => {
|
||||
currentQueryIdRef.current = '';
|
||||
setCurrentQueryId('');
|
||||
};
|
||||
|
||||
// Handle Resizing
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -984,6 +998,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
message.error("请先选择数据库");
|
||||
return;
|
||||
}
|
||||
// 如果已有查询在运行,先取消它
|
||||
if (currentQueryIdRef.current) {
|
||||
try {
|
||||
await CancelQuery(currentQueryIdRef.current);
|
||||
} catch (error) {
|
||||
// 忽略取消错误,可能查询已完成
|
||||
}
|
||||
// 清除旧查询ID
|
||||
clearQueryId();
|
||||
}
|
||||
const runSeq = ++runSeqRef.current;
|
||||
setLoading(true);
|
||||
const runStartTime = Date.now();
|
||||
@@ -1037,7 +1061,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const limited = limitApplied ? applyAutoLimit(rawStatement, dbType, probeLimit) : { sql: rawStatement, applied: false, maxRows: probeLimit };
|
||||
const executedSql = limited.sql;
|
||||
const startTime = Date.now();
|
||||
const res = await DBQuery(config as any, currentDb, executedSql);
|
||||
|
||||
// Generate query ID for cancellation using backend UUID with fallback
|
||||
let queryId: string;
|
||||
try {
|
||||
queryId = await GenerateQueryID();
|
||||
} catch (error) {
|
||||
console.warn('GenerateQueryID failed, using local UUID fallback:', error);
|
||||
queryId = 'query-' + uuidv4();
|
||||
}
|
||||
setQueryId(queryId);
|
||||
|
||||
const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
addSqlLog({
|
||||
@@ -1052,6 +1087,32 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
// 检查是否为查询取消错误
|
||||
const errorMsg = res.message.toLowerCase();
|
||||
const isCancelledError = errorMsg.includes('context canceled') ||
|
||||
errorMsg.includes('查询已取消') ||
|
||||
errorMsg.includes('canceled') ||
|
||||
errorMsg.includes('cancelled') ||
|
||||
errorMsg.includes('statement canceled') ||
|
||||
errorMsg.includes('sql: statement canceled');
|
||||
|
||||
// 确保不是超时错误
|
||||
const isTimeoutError = errorMsg.includes('context deadline exceeded') ||
|
||||
errorMsg.includes('timeout') ||
|
||||
errorMsg.includes('超时') ||
|
||||
errorMsg.includes('deadline exceeded');
|
||||
|
||||
if (isCancelledError && !isTimeoutError) {
|
||||
// 查询已被用户取消,不显示错误消息,清理状态
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
// 清除查询ID,与handleCancel保持一致
|
||||
if (currentQueryIdRef.current) {
|
||||
clearQueryId();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : '';
|
||||
message.error(prefix + res.message);
|
||||
setResultSets([]);
|
||||
@@ -1157,6 +1218,30 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setActiveResultKey('');
|
||||
} finally {
|
||||
if (runSeqRef.current === runSeq) setLoading(false);
|
||||
// Clear query ID after execution completes
|
||||
clearQueryId();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!currentQueryIdRef.current) {
|
||||
message.warning('没有正在运行的查询可取消');
|
||||
return;
|
||||
}
|
||||
const queryIdToCancel = currentQueryIdRef.current;
|
||||
try {
|
||||
const res = await CancelQuery(queryIdToCancel);
|
||||
if (res.success) {
|
||||
message.success('查询已取消');
|
||||
// Clear query ID after successful cancellation
|
||||
if (currentQueryIdRef.current === queryIdToCancel) {
|
||||
clearQueryId()
|
||||
}
|
||||
} else {
|
||||
message.warning(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('取消查询失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1271,9 +1356,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
||||
运行
|
||||
</Button>
|
||||
<Button.Group>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
||||
运行
|
||||
</Button>
|
||||
{loading && (
|
||||
<Button type="primary" danger icon={<StopOutlined />} onClick={handleCancel}>
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
</Button.Group>
|
||||
<Button icon={<SaveOutlined />} onClick={() => {
|
||||
saveForm.setFieldsValue({ name: tab.title.replace('Query (', '').replace(')', '') });
|
||||
setIsSaveModalOpen(true);
|
||||
|
||||
9
frontend/wailsjs/go/app/App.d.ts
vendored
9
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -1,15 +1,20 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {connection} from '../models';
|
||||
import {time} from '../models';
|
||||
import {sync} from '../models';
|
||||
import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function CleanupStaleQueries(arg1:time.Duration):Promise<void>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
|
||||
@@ -36,6 +41,8 @@ export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string
|
||||
|
||||
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
|
||||
@@ -68,6 +75,8 @@ export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string
|
||||
|
||||
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;
|
||||
|
||||
export function GenerateQueryID():Promise<string>;
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CancelQuery(arg1) {
|
||||
return window['go']['app']['App']['CancelQuery'](arg1);
|
||||
}
|
||||
|
||||
export function CheckDriverNetworkStatus() {
|
||||
return window['go']['app']['App']['CheckDriverNetworkStatus']();
|
||||
}
|
||||
@@ -14,6 +18,10 @@ export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function CleanupStaleQueries(arg1) {
|
||||
return window['go']['app']['App']['CleanupStaleQueries'](arg1);
|
||||
}
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
|
||||
}
|
||||
@@ -66,6 +74,10 @@ export function DBQueryIsolated(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DBShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -130,6 +142,10 @@ export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function GenerateQueryID() {
|
||||
return window['go']['app']['App']['GenerateQueryID']();
|
||||
}
|
||||
|
||||
export function GetAppInfo() {
|
||||
return window['go']['app']['App']['GetAppInfo']();
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ export namespace connection {
|
||||
message: string;
|
||||
data: any;
|
||||
fields?: string[];
|
||||
queryId?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QueryResult(source);
|
||||
@@ -187,6 +188,7 @@ export namespace connection {
|
||||
this.message = source["message"];
|
||||
this.data = source["data"];
|
||||
this.fields = source["fields"];
|
||||
this.queryId = source["queryId"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/highgo/pq-sm3 v0.0.0
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/microsoft/go-mssqldb v1.9.6
|
||||
@@ -49,7 +50,6 @@ require (
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const dbCachePingInterval = 30 * time.Second
|
||||
@@ -25,19 +26,27 @@ type cachedDatabase struct {
|
||||
lastPing time.Time
|
||||
}
|
||||
|
||||
type queryContext struct {
|
||||
cancel context.CancelFunc
|
||||
started time.Time
|
||||
}
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
ctx context.Context
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
queryMu sync.RWMutex
|
||||
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{
|
||||
dbCache: make(map[string]cachedDatabase),
|
||||
dbCache: make(map[string]cachedDatabase),
|
||||
runningQueries: make(map[string]queryContext),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,3 +417,43 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
// generateQueryID generates a unique ID for a query using UUID v4
|
||||
func generateQueryID() string {
|
||||
return "query-" + uuid.New().String()
|
||||
}
|
||||
|
||||
// CancelQuery cancels a running query by its ID
|
||||
func (a *App) CancelQuery(queryID string) connection.QueryResult {
|
||||
a.queryMu.Lock()
|
||||
defer a.queryMu.Unlock()
|
||||
|
||||
if ctx, exists := a.runningQueries[queryID]; exists {
|
||||
ctx.cancel()
|
||||
delete(a.runningQueries, queryID)
|
||||
logger.Infof("查询已取消:queryID=%s", queryID)
|
||||
return connection.QueryResult{Success: true, Message: "查询已取消"}
|
||||
}
|
||||
logger.Warnf("取消查询失败:queryID=%s 不存在或已完成", queryID)
|
||||
return connection.QueryResult{Success: false, Message: "查询不存在或已完成"}
|
||||
}
|
||||
|
||||
// CleanupStaleQueries removes queries older than maxAge
|
||||
func (a *App) CleanupStaleQueries(maxAge time.Duration) {
|
||||
a.queryMu.Lock()
|
||||
defer a.queryMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, ctx := range a.runningQueries {
|
||||
if now.Sub(ctx.started) > maxAge {
|
||||
// Query likely finished or stuck, remove from tracking
|
||||
delete(a.runningQueries, id)
|
||||
// Query expired, silently remove
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateQueryID generates a unique query ID for cancellation tracking
|
||||
func (a *App) GenerateQueryID() string {
|
||||
return generateQueryID()
|
||||
}
|
||||
|
||||
@@ -376,12 +376,21 @@ func (a *App) MySQLShowCreateTable(config connection.ConnectionConfig, dbName st
|
||||
}
|
||||
|
||||
func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||
return a.DBQueryWithCancel(config, dbName, query, "")
|
||||
}
|
||||
|
||||
func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
// Generate query ID if not provided
|
||||
if queryID == "" {
|
||||
queryID = generateQueryID()
|
||||
}
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQuery 获取连接失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
|
||||
query = sanitizeSQLForPgLike(runConfig.Type, query)
|
||||
@@ -392,6 +401,21 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
|
||||
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Store cancel function for potential manual cancellation
|
||||
a.queryMu.Lock()
|
||||
a.runningQueries[queryID] = queryContext{
|
||||
cancel: cancel,
|
||||
started: time.Now(),
|
||||
}
|
||||
a.queryMu.Unlock()
|
||||
|
||||
// Ensure query is removed from tracking when done
|
||||
defer func() {
|
||||
a.queryMu.Lock()
|
||||
delete(a.runningQueries, queryID)
|
||||
a.queryMu.Unlock()
|
||||
}()
|
||||
|
||||
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
||||
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
|
||||
// MongoDB JSON 命令中的 find/count/aggregate 也属于读查询
|
||||
@@ -410,9 +434,9 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQuery 查询失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: data, Fields: columns}
|
||||
return connection.QueryResult{Success: true, Data: data, Fields: columns, QueryID: queryID}
|
||||
} else {
|
||||
var affected int64
|
||||
if e, ok := dbInst.(interface {
|
||||
@@ -424,9 +448,9 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQuery 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}}
|
||||
return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}, QueryID: queryID}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
149
internal/app/methods_db_cancel_test.go
Normal file
149
internal/app/methods_db_cancel_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestGenerateQueryID(t *testing.T) {
|
||||
app := NewApp()
|
||||
id := app.GenerateQueryID()
|
||||
if id == "" {
|
||||
t.Fatal("GenerateQueryID returned empty string")
|
||||
}
|
||||
// Should start with "query-"
|
||||
if !strings.HasPrefix(id, "query-") {
|
||||
t.Fatalf("Expected query ID to start with 'query-', got: %s", id)
|
||||
}
|
||||
// Should be reasonably unique (not equal to another generated ID)
|
||||
id2 := app.GenerateQueryID()
|
||||
if id == id2 {
|
||||
t.Fatal("Two consecutive GenerateQueryID calls returned identical IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelQuery_NonExistent(t *testing.T) {
|
||||
app := NewApp()
|
||||
res := app.CancelQuery("non-existent-query-id")
|
||||
if res.Success {
|
||||
t.Fatal("CancelQuery should fail for non-existent query ID")
|
||||
}
|
||||
if !strings.Contains(res.Message, "不存在") && !strings.Contains(res.Message, "not exist") {
|
||||
t.Fatalf("Expected error message about query not existing, got: %s", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelQuery_ValidQuery(t *testing.T) {
|
||||
app := NewApp()
|
||||
|
||||
// First, generate a query ID and simulate a running query
|
||||
queryID := app.GenerateQueryID()
|
||||
|
||||
// Store a cancel function in runningQueries map
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
app.queryMu.Lock()
|
||||
app.runningQueries[queryID] = queryContext{
|
||||
cancel: cancel,
|
||||
started: time.Now(),
|
||||
}
|
||||
app.queryMu.Unlock()
|
||||
|
||||
// Ensure cleanup after test
|
||||
defer func() {
|
||||
app.queryMu.Lock()
|
||||
delete(app.runningQueries, queryID)
|
||||
app.queryMu.Unlock()
|
||||
}()
|
||||
|
||||
// Cancel the query
|
||||
res := app.CancelQuery(queryID)
|
||||
if !res.Success {
|
||||
t.Fatalf("CancelQuery should succeed for valid query ID, got: %s", res.Message)
|
||||
}
|
||||
|
||||
// Verify query removed from map
|
||||
app.queryMu.Lock()
|
||||
_, exists := app.runningQueries[queryID]
|
||||
app.queryMu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("Query should be removed from runningQueries after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleQueries(t *testing.T) {
|
||||
app := NewApp()
|
||||
|
||||
// Add a stale query (started 2 hours ago)
|
||||
queryID := app.GenerateQueryID()
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
app.queryMu.Lock()
|
||||
app.runningQueries[queryID] = queryContext{
|
||||
cancel: cancel,
|
||||
started: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
app.queryMu.Unlock()
|
||||
|
||||
// Cleanup queries older than 1 hour
|
||||
app.CleanupStaleQueries(1 * time.Hour)
|
||||
|
||||
// Verify stale query was removed
|
||||
app.queryMu.Lock()
|
||||
_, exists := app.runningQueries[queryID]
|
||||
app.queryMu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("Stale query should be removed by CleanupStaleQueries")
|
||||
}
|
||||
|
||||
// Add a fresh query (started 30 minutes ago)
|
||||
freshID := app.GenerateQueryID()
|
||||
_, cancel2 := context.WithCancel(context.Background())
|
||||
app.queryMu.Lock()
|
||||
app.runningQueries[freshID] = queryContext{
|
||||
cancel: cancel2,
|
||||
started: time.Now().Add(-30 * time.Minute),
|
||||
}
|
||||
app.queryMu.Unlock()
|
||||
defer cancel2()
|
||||
|
||||
// Cleanup queries older than 1 hour
|
||||
app.CleanupStaleQueries(1 * time.Hour)
|
||||
|
||||
// Verify fresh query still exists
|
||||
app.queryMu.Lock()
|
||||
_, exists = app.runningQueries[freshID]
|
||||
app.queryMu.Unlock()
|
||||
if !exists {
|
||||
t.Fatal("Fresh query should not be removed by CleanupStaleQueries")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
app.queryMu.Lock()
|
||||
delete(app.runningQueries, freshID)
|
||||
app.queryMu.Unlock()
|
||||
}
|
||||
|
||||
func TestDBQueryWithCancel_QueryIDPropagation(t *testing.T) {
|
||||
// This test verifies that query ID is properly propagated in QueryResult
|
||||
// Since we can't easily mock database connections, we'll test the integration
|
||||
// by checking that DBQueryWithCancel returns a QueryResult with QueryID field
|
||||
|
||||
app := NewApp()
|
||||
|
||||
// Create a minimal config for a database type that doesn't require actual connection
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "duckdb",
|
||||
Host: ":memory:", // In-memory duckdb for testing
|
||||
}
|
||||
|
||||
// This will fail because we can't actually connect, but we can test the error path
|
||||
result := app.DBQueryWithCancel(config, "", "SELECT 1", "test-query-id")
|
||||
|
||||
// The query should fail (no actual database), but QueryID should be present
|
||||
if result.QueryID != "test-query-id" {
|
||||
t.Fatalf("Expected QueryID 'test-query-id' in result, got: %s", result.QueryID)
|
||||
}
|
||||
}
|
||||
@@ -696,11 +696,11 @@ func (a *App) CheckDriverNetworkStatus() connection.QueryResult {
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"reachable": allReachable,
|
||||
"summary": summary,
|
||||
"recommendedProxy": !allReachable,
|
||||
"proxyConfigured": proxyConfigured,
|
||||
"proxyEnv": proxyEnv,
|
||||
"reachable": allReachable,
|
||||
"summary": summary,
|
||||
"recommendedProxy": !allReachable,
|
||||
"proxyConfigured": proxyConfigured,
|
||||
"proxyEnv": proxyEnv,
|
||||
"downloadChainReachable": downloadChainReachable,
|
||||
"downloadRequiredHosts": []string{
|
||||
"github.com",
|
||||
@@ -709,8 +709,8 @@ func (a *App) CheckDriverNetworkStatus() connection.QueryResult {
|
||||
"objects.githubusercontent.com",
|
||||
"raw.githubusercontent.com",
|
||||
},
|
||||
"checkedAt": time.Now().Format(time.RFC3339),
|
||||
"checks": checks,
|
||||
"checkedAt": time.Now().Format(time.RFC3339),
|
||||
"checks": checks,
|
||||
}
|
||||
if logPath := strings.TrimSpace(logger.Path()); logPath != "" {
|
||||
data["logPath"] = logPath
|
||||
|
||||
@@ -55,6 +55,7 @@ type QueryResult struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
QueryID string `json:"queryId,omitempty"` // Unique ID for query cancellation
|
||||
}
|
||||
|
||||
// ColumnDefinition represents a table column
|
||||
|
||||
Reference in New Issue
Block a user