From 8d5a24992a5ceca350c44b2984830701cea4ac4f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 14 Jun 2026 12:40:31 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sql-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=8B=E5=8A=A1=E6=89=A7=E8=A1=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=B8=8E=E5=B7=A5=E5=85=B7=E6=A0=8F=E5=B8=83=E5=B1=80=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/optional-driver-agent/main.go | 210 +++++++++++++++--- cmd/optional-driver-agent/main_test.go | 112 +++++++++- .../QueryEditor.external-sql-save.test.tsx | 6 +- .../QueryEditorToolbar.layout.test.tsx | 23 ++ .../src/components/QueryEditorToolbar.tsx | 39 +++- frontend/src/v2-theme.css | 50 ++++- internal/db/optional_driver_agent_impl.go | 105 +++++++++ 7 files changed, 500 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/QueryEditorToolbar.layout.test.tsx diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index bf24273..4915947 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "reflect" + "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( type agentRequest struct { ID int64 `json:"id"` Method string `json:"method"` + SessionID string `json:"sessionId,omitempty"` Config *connection.ConnectionConfig `json:"config,omitempty"` Query string `json:"query,omitempty"` TimeoutMs int64 `json:"timeoutMs,omitempty"` @@ -39,6 +41,8 @@ const ( agentMethodClose = "close" agentMethodMetadata = "metadata" agentMethodPing = "ping" + agentMethodOpenSession = "openSession" + agentMethodCloseSession = "closeSession" agentMethodQuery = "query" agentMethodExec = "exec" agentMethodGetDatabases = "getDatabases" @@ -59,6 +63,12 @@ var ( agentDatabaseFactory func() db.Database ) +type agentRuntime struct { + inst db.Database + sessions map[string]db.StatementExecer + nextSessionID int64 +} + func main() { if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" { fmt.Fprintf(os.Stderr, "未配置驱动代理 provider,请使用 gonavi__driver 标签构建\n") @@ -70,7 +80,9 @@ func main() { writer := bufio.NewWriter(os.Stdout) defer writer.Flush() - var inst db.Database + runtimeState := &agentRuntime{ + sessions: make(map[string]db.StatementExecer), + } for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { @@ -87,23 +99,21 @@ func main() { continue } - resp := handleRequest(&inst, req) + resp := handleRequest(runtimeState, req) if err := writeResponse(writer, resp); err != nil { fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err) break } } - if inst != nil { - _ = inst.Close() - } + runtimeState.close() if err := scanner.Err(); err != nil { fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err) } } -func handleRequest(inst *db.Database, req agentRequest) agentResponse { +func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse { resp := agentResponse{ID: req.ID, Success: true} method := strings.TrimSpace(req.Method) @@ -112,9 +122,7 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { if req.Config == nil { return fail(resp, "连接配置为空") } - if *inst != nil { - _ = (*inst).Close() - } + runtimeState.close() next := agentDatabaseFactory() if next == nil { return fail(resp, "驱动代理初始化失败") @@ -122,14 +130,13 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { if err := next.Connect(*req.Config); err != nil { return fail(resp, err.Error()) } - *inst = next + runtimeState.inst = next return resp case agentMethodClose: - if *inst != nil { - if err := (*inst).Close(); err != nil { + if runtimeState.inst != nil { + if err := runtimeState.close(); err != nil { return fail(resp, err.Error()) } - *inst = nil } return resp case agentMethodMetadata: @@ -139,74 +146,124 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { "protocolSchema": "json-lines-v1", } return resp + case agentMethodOpenSession: + if runtimeState.inst == nil { + return fail(resp, "connection not open") + } + provider, ok := runtimeState.inst.(db.SessionExecerProvider) + if !ok { + return fail(resp, fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", strings.TrimSpace(agentDriverType))) + } + openCtx := context.Background() + var cancel context.CancelFunc + if req.TimeoutMs > 0 { + openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(req.TimeoutMs)*time.Millisecond) + defer cancel() + } + session, err := provider.OpenSessionExecer(openCtx) + if err != nil { + return fail(resp, err.Error()) + } + sessionID := runtimeState.nextID() + runtimeState.sessions[sessionID] = session + resp.Data = sessionID + return resp + case agentMethodCloseSession: + if err := runtimeState.closeSession(req.SessionID); err != nil { + return fail(resp, err.Error()) + } + return resp } - if *inst == nil { + if runtimeState.inst == nil { return fail(resp, "connection not open") } + if session, ok, err := runtimeState.session(req.SessionID); err != nil { + return fail(resp, err.Error()) + } else if ok { + switch method { + case agentMethodQuery: + data, fields, err := queryStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + resp.Fields = fields + case agentMethodExec: + affected, err := execStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs) + if err != nil { + return fail(resp, err.Error()) + } + resp.RowsAffected = affected + default: + return fail(resp, "当前事务会话不支持该方法") + } + return resp + } + switch method { case agentMethodPing: - if err := (*inst).Ping(); err != nil { + if err := runtimeState.inst.Ping(); err != nil { return fail(resp, err.Error()) } case agentMethodQuery: - data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs) + data, fields, err := queryWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs) if err != nil { return fail(resp, err.Error()) } resp.Data = data resp.Fields = fields case agentMethodExec: - affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs) + affected, err := execWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs) if err != nil { return fail(resp, err.Error()) } resp.RowsAffected = affected case agentMethodGetDatabases: - data, err := (*inst).GetDatabases() + data, err := runtimeState.inst.GetDatabases() if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetTables: - data, err := (*inst).GetTables(req.DBName) + data, err := runtimeState.inst.GetTables(req.DBName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetCreateStmt: - data, err := (*inst).GetCreateStatement(req.DBName, req.TableName) + data, err := runtimeState.inst.GetCreateStatement(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetColumns: - data, err := (*inst).GetColumns(req.DBName, req.TableName) + data, err := runtimeState.inst.GetColumns(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetAllColumns: - data, err := (*inst).GetAllColumns(req.DBName) + data, err := runtimeState.inst.GetAllColumns(req.DBName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetIndexes: - data, err := (*inst).GetIndexes(req.DBName, req.TableName) + data, err := runtimeState.inst.GetIndexes(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetForeignKey: - data, err := (*inst).GetForeignKeys(req.DBName, req.TableName) + data, err := runtimeState.inst.GetForeignKeys(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } resp.Data = data case agentMethodGetTriggers: - data, err := (*inst).GetTriggers(req.DBName, req.TableName) + data, err := runtimeState.inst.GetTriggers(req.DBName, req.TableName) if err != nil { return fail(resp, err.Error()) } @@ -215,7 +272,7 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { if req.Changes == nil { return fail(resp, "变更集为空") } - applier, ok := (*inst).(interface { + applier, ok := runtimeState.inst.(interface { ApplyChanges(tableName string, changes connection.ChangeSet) error }) if !ok { @@ -231,6 +288,67 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse { return resp } +func (r *agentRuntime) nextID() string { + r.ensureSessionMap() + r.nextSessionID++ + return "session-" + strconv.FormatInt(r.nextSessionID, 10) +} + +func (r *agentRuntime) session(sessionID string) (db.StatementExecer, bool, error) { + r.ensureSessionMap() + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, false, nil + } + session, ok := r.sessions[sessionID] + if !ok || session == nil { + return nil, false, fmt.Errorf("事务会话不存在或已结束") + } + return session, true, nil +} + +func (r *agentRuntime) closeSession(sessionID string) error { + r.ensureSessionMap() + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return fmt.Errorf("事务会话 ID 不能为空") + } + session, ok := r.sessions[sessionID] + if ok { + delete(r.sessions, sessionID) + } + if !ok || session == nil { + return fmt.Errorf("事务会话不存在或已结束") + } + return session.Close() +} + +func (r *agentRuntime) close() error { + var closeErr error + r.ensureSessionMap() + for sessionID, session := range r.sessions { + delete(r.sessions, sessionID) + if session != nil { + if err := session.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + } + if r.inst != nil { + if err := r.inst.Close(); err != nil && closeErr == nil { + closeErr = err + } + r.inst = nil + } + return closeErr +} + +func (r *agentRuntime) ensureSessionMap() { + if r.sessions == nil { + r.sessions = make(map[string]db.StatementExecer) + } +} + func writeResponse(writer *bufio.Writer, resp agentResponse) error { // 对响应数据做统一 JSON 安全归一化: // 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。 @@ -301,7 +419,23 @@ func normalizeAgentResponseData(v interface{}) interface{} { } } -func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { +type agentQueryRunner interface { + Query(string) ([]map[string]interface{}, []string, error) +} + +type agentQueryContextRunner interface { + QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) +} + +type agentExecRunner interface { + Exec(string) (int64, error) +} + +type agentExecContextRunner interface { + ExecContext(context.Context, string) (int64, error) +} + +func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { effectiveTimeoutMs := timeoutMs if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") { effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond) @@ -309,9 +443,7 @@ func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ( if effectiveTimeoutMs <= 0 { return inst.Query(query) } - if q, ok := inst.(interface { - QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) - }); ok { + if q, ok := inst.(agentQueryContextRunner); ok { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond) defer cancel() return q.QueryContext(ctx, query) @@ -319,7 +451,15 @@ func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ( return inst.Query(query) } -func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) { +func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) { + queryRunner, ok := inst.(agentQueryRunner) + if !ok { + return nil, nil, fmt.Errorf("当前事务会话不支持查询语句") + } + return queryWithOptionalTimeout(queryRunner, query, timeoutMs) +} + +func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64) (int64, error) { effectiveTimeoutMs := timeoutMs if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") { effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond) @@ -327,12 +467,14 @@ func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (i if effectiveTimeoutMs <= 0 { return inst.Exec(query) } - if e, ok := inst.(interface { - ExecContext(context.Context, string) (int64, error) - }); ok { + if e, ok := inst.(agentExecContextRunner); ok { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond) defer cancel() return e.ExecContext(ctx, query) } return inst.Exec(query) } + +func execStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) (int64, error) { + return execWithOptionalTimeout(inst, query, timeoutMs) +} diff --git a/cmd/optional-driver-agent/main_test.go b/cmd/optional-driver-agent/main_test.go index 7e082e1..da976a0 100644 --- a/cmd/optional-driver-agent/main_test.go +++ b/cmd/optional-driver-agent/main_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -77,8 +78,8 @@ func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) { agentDriverType = "clickhouse" agentDatabaseFactory = func() db.Database { return nil } - var inst db.Database - resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata}) + runtimeState := &agentRuntime{sessions: make(map[string]db.StatementExecer)} + resp := handleRequest(runtimeState, agentRequest{ID: 7, Method: agentMethodMetadata}) if !resp.Success { t.Fatalf("metadata request failed: %s", resp.Error) } @@ -150,6 +151,45 @@ func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection return nil, nil } +type fakeAgentSessionDB struct { + fakeAgentTimeoutDB + session *fakeAgentStatementSession +} + +func (f *fakeAgentSessionDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) { + f.session = &fakeAgentStatementSession{} + return f.session, nil +} + +type fakeAgentStatementSession struct { + queryCalls int + execCalls int + closed bool +} + +func (f *fakeAgentStatementSession) Query(query string) ([]map[string]interface{}, []string, error) { + return f.QueryContext(context.Background(), query) +} + +func (f *fakeAgentStatementSession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + f.queryCalls++ + return []map[string]interface{}{{"session_ok": 1}}, []string{"session_ok"}, nil +} + +func (f *fakeAgentStatementSession) Exec(query string) (int64, error) { + return f.ExecContext(context.Background(), query) +} + +func (f *fakeAgentStatementSession) ExecContext(ctx context.Context, query string) (int64, error) { + f.execCalls++ + return 9, nil +} + +func (f *fakeAgentStatementSession) Close() error { + f.closed = true + return nil +} + func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) { fake := &fakeAgentTimeoutDB{} data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds())) @@ -198,3 +238,71 @@ func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testin t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled) } } + +func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.T) { + old := agentDriverType + defer func() { agentDriverType = old }() + agentDriverType = "sqlserver" + + fake := &fakeAgentSessionDB{} + runtimeState := &agentRuntime{ + inst: fake, + sessions: make(map[string]db.StatementExecer), + } + + openResp := handleRequest(runtimeState, agentRequest{ID: 1, Method: agentMethodOpenSession}) + if !openResp.Success { + t.Fatalf("openSession failed: %s", openResp.Error) + } + sessionID, ok := openResp.Data.(string) + if !ok || strings.TrimSpace(sessionID) == "" { + t.Fatalf("unexpected session id payload: %#v", openResp.Data) + } + if fake.session == nil { + t.Fatal("expected OpenSessionExecer to create a pinned session") + } + + queryResp := handleRequest(runtimeState, agentRequest{ + ID: 2, + Method: agentMethodQuery, + SessionID: sessionID, + Query: "SELECT 1", + }) + if !queryResp.Success { + t.Fatalf("session query failed: %s", queryResp.Error) + } + if fake.queryCalled || fake.queryContextCalled { + t.Fatalf("expected session query to bypass database-level query path, got Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled) + } + if fake.session.queryCalls != 1 { + t.Fatalf("expected pinned session queryCalls=1, got %d", fake.session.queryCalls) + } + + execResp := handleRequest(runtimeState, agentRequest{ + ID: 3, + Method: agentMethodExec, + SessionID: sessionID, + Query: "UPDATE t SET v = 1", + }) + if !execResp.Success { + t.Fatalf("session exec failed: %s", execResp.Error) + } + if fake.execCalled || fake.execContextCalled { + t.Fatalf("expected session exec to bypass database-level exec path, got Exec=%v ExecContext=%v", fake.execCalled, fake.execContextCalled) + } + if fake.session.execCalls != 1 { + t.Fatalf("expected pinned session execCalls=1, got %d", fake.session.execCalls) + } + + closeResp := handleRequest(runtimeState, agentRequest{ + ID: 4, + Method: agentMethodCloseSession, + SessionID: sessionID, + }) + if !closeResp.Success { + t.Fatalf("closeSession failed: %s", closeResp.Error) + } + if !fake.session.closed { + t.Fatal("expected pinned session to close") + } +} diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 027a6b0..0834005 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -3832,9 +3832,9 @@ describe('QueryEditor external SQL save', () => { expect(css).toContain('width: 34px !important;'); expect(css).toContain('@media (max-width: 900px)'); - const queryToolbarCss = css.slice(css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar {'), css.indexOf('body[data-ui-version="v2"] .gn-v2-query-monaco-shell {')); - expect(queryToolbarCss).not.toContain('margin-left: auto;'); - expect(queryToolbarCss).not.toContain('justify-content: flex-end;'); + const queryToolbarMainCss = css.slice(css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-main {'), css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects {')); + expect(queryToolbarMainCss).not.toContain('margin-left: auto;'); + expect(queryToolbarMainCss).not.toContain('justify-content: flex-end;'); }); it('keeps custom SQL snippet syntax help editable and uses it in completion details', () => { diff --git a/frontend/src/components/QueryEditorToolbar.layout.test.tsx b/frontend/src/components/QueryEditorToolbar.layout.test.tsx new file mode 100644 index 0000000..fd35e11 --- /dev/null +++ b/frontend/src/components/QueryEditorToolbar.layout.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('QueryEditorToolbar layout', () => { + it('keeps pending transaction controls outside the main v2 toolbar row', () => { + const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); + const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + + expect(toolbarSource).toContain('gn-v2-query-toolbar-main'); + expect(toolbarSource).toContain('gn-v2-query-toolbar-transaction-row'); + expect(toolbarSource).toContain('{pendingTransactionToolbar && ('); + expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-main'); + expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-row'); + }); + + it('keeps commit button hover styling in source and v2 css', () => { + const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + + expect(css).toContain('.gn-v2-query-transaction-commit-button:hover'); + expect(css).toContain('.gn-v2-query-transaction-commit-button:focus-visible'); + }); +}); diff --git a/frontend/src/components/QueryEditorToolbar.tsx b/frontend/src/components/QueryEditorToolbar.tsx index 28a883b..723c5d3 100644 --- a/frontend/src/components/QueryEditorToolbar.tsx +++ b/frontend/src/components/QueryEditorToolbar.tsx @@ -77,8 +77,8 @@ const QueryEditorToolbar: React.FC = ({ onFormat, onToggleResultPanelVisibility, onAIAction, -}) => ( -
+}) => { + const selects = (
= ({ onCommitModeChange={onCommitModeChange} onAutoCommitDelayMsChange={onAutoCommitDelayMsChange} /> - {pendingTransactionToolbar} + {!isV2Ui && pendingTransactionToolbar}
+ ); + + const actions = (
= ({
-
-); + ); + + if (!isV2Ui) { + return ( +
+ {selects} + {actions} +
+ ); + } + + return ( +
+
+ {selects} + {actions} +
+ {pendingTransactionToolbar && ( +
+ {pendingTransactionToolbar} +
+ )} +
+ ); +}; export default QueryEditorToolbar; diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index 541057a..467e8c9 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -4796,11 +4796,22 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar { min-height: 48px; padding: 8px 12px !important; gap: 6px 10px !important; - align-items: center !important; + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch !important; background: var(--gn-bg-panel) !important; border-bottom: 0.5px solid var(--gn-br-1) !important; } +body[data-ui-version="v2"] .gn-v2-query-toolbar-main { + display: flex; + width: 100%; + min-width: 0; + gap: 6px 10px !important; + align-items: center !important; + flex-wrap: wrap; +} + body[data-ui-version="v2"] .gn-v2-query-toolbar-selects, body[data-ui-version="v2"] .gn-v2-query-toolbar-actions { min-width: 0; @@ -4819,6 +4830,18 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-actions { justify-content: flex-start; } +body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-row { + display: flex; + width: 100%; + min-width: 0; + justify-content: flex-end; +} + +body[data-ui-version="v2"] .gn-v2-query-transaction-toolbar { + display: inline-flex; + max-width: 100%; +} + body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-select { min-width: 0; } @@ -4897,6 +4920,15 @@ body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button { font-weight: 750 !important; } +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:hover, +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus, +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus-visible, +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:active { + border-color: transparent !important; + background: var(--gn-accent-soft) !important; + color: var(--gn-accent-2) !important; +} + body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button .gn-v2-toolbar-kbd { margin-left: 0; min-width: 18px; @@ -4906,6 +4938,14 @@ body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button .gn-v2-toolbar color: var(--gn-accent-2); } +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:hover .gn-v2-toolbar-kbd, +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus .gn-v2-toolbar-kbd, +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:focus-visible .gn-v2-toolbar-kbd, +body[data-ui-version="v2"] .gn-v2-query-transaction-commit-button:active .gn-v2-toolbar-kbd { + background: rgba(22, 163, 74, 0.18); + color: var(--gn-accent-2); +} + body[data-ui-version="v2"] .gn-v2-query-toolbar-icon-action.ant-btn, body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-btn-icon-only { width: 34px !important; @@ -4923,6 +4963,10 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-ai-action.ant-btn { } @media (max-width: 900px) { + body[data-ui-version="v2"] .gn-v2-query-toolbar-main { + width: 100%; + } + body[data-ui-version="v2"] .gn-v2-query-toolbar-selects { flex: 1 1 100% !important; max-width: none; @@ -4932,6 +4976,10 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-ai-action.ant-btn { width: 100%; justify-content: flex-start; } + + body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-row { + justify-content: flex-start; + } } body[data-ui-version="v2"] .gn-v2-query-monaco-shell { diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go index 18c0624..5c65da5 100644 --- a/internal/db/optional_driver_agent_impl.go +++ b/internal/db/optional_driver_agent_impl.go @@ -25,6 +25,8 @@ const ( optionalAgentMethodClose = "close" optionalAgentMethodMetadata = "metadata" optionalAgentMethodPing = "ping" + optionalAgentMethodOpenSession = "openSession" + optionalAgentMethodCloseSession = "closeSession" optionalAgentMethodQuery = "query" optionalAgentMethodExec = "exec" optionalAgentMethodGetDatabases = "getDatabases" @@ -43,6 +45,7 @@ const ( type optionalAgentRequest struct { ID int64 `json:"id"` Method string `json:"method"` + SessionID string `json:"sessionId,omitempty"` Config *connection.ConnectionConfig `json:"config,omitempty"` Query string `json:"query,omitempty"` TimeoutMs int64 `json:"timeoutMs,omitempty"` @@ -298,6 +301,14 @@ type OptionalDriverAgentDB struct { client *optionalDriverAgentClient } +type optionalDriverAgentSession struct { + client *optionalDriverAgentClient + driver string + sessionID string + mu sync.Mutex + closed bool +} + func newOptionalDriverAgentDatabase(driverType string) databaseFactory { normalized := normalizeRuntimeDriverType(driverType) return func() Database { @@ -420,6 +431,100 @@ func (d *OptionalDriverAgentDB) Exec(query string) (int64, error) { return affected, nil } +func (d *OptionalDriverAgentDB) OpenSessionExecer(ctx context.Context) (StatementExecer, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var sessionID string + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodOpenSession, + TimeoutMs: timeoutMsFromContext(ctx), + }, &sessionID, nil, nil); err != nil { + return nil, err + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, fmt.Errorf("%s 驱动代理未返回事务会话 ID", driverDisplayName(d.driverType)) + } + return &optionalDriverAgentSession{ + client: client, + driver: d.driverType, + sessionID: sessionID, + }, nil +} + +func (s *optionalDriverAgentSession) Query(query string) ([]map[string]interface{}, []string, error) { + return s.QueryContext(context.Background(), query) +} + +func (s *optionalDriverAgentSession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + if err := s.ensureOpen(); err != nil { + return nil, nil, err + } + var data []map[string]interface{} + var fields []string + if err := s.client.call(optionalAgentRequest{ + Method: optionalAgentMethodQuery, + SessionID: s.sessionID, + Query: query, + TimeoutMs: timeoutMsFromContext(ctx), + }, &data, &fields, nil); err != nil { + return nil, nil, err + } + return data, fields, nil +} + +func (s *optionalDriverAgentSession) Exec(query string) (int64, error) { + return s.ExecContext(context.Background(), query) +} + +func (s *optionalDriverAgentSession) ExecContext(ctx context.Context, query string) (int64, error) { + if err := s.ensureOpen(); err != nil { + return 0, err + } + var affected int64 + if err := s.client.call(optionalAgentRequest{ + Method: optionalAgentMethodExec, + SessionID: s.sessionID, + Query: query, + TimeoutMs: timeoutMsFromContext(ctx), + }, nil, nil, &affected); err != nil { + return 0, err + } + return affected, nil +} + +func (s *optionalDriverAgentSession) Close() error { + if s == nil { + return nil + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + sessionID := s.sessionID + s.mu.Unlock() + return s.client.call(optionalAgentRequest{ + Method: optionalAgentMethodCloseSession, + SessionID: sessionID, + }, nil, nil, nil) +} + +func (s *optionalDriverAgentSession) ensureOpen() error { + if s == nil || s.client == nil { + return fmt.Errorf("连接未打开") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || strings.TrimSpace(s.sessionID) == "" { + return fmt.Errorf("%s 事务会话已关闭", driverDisplayName(s.driver)) + } + return nil +} + func (d *OptionalDriverAgentDB) GetDatabases() ([]string, error) { client, err := d.requireClient() if err != nil {