🐛 fix(sql-editor): 修复事务执行会话与工具栏布局交互

This commit is contained in:
Syngnat
2026-06-14 12:40:31 +08:00
parent 7a85c30752
commit 8d5a24992a
7 changed files with 500 additions and 45 deletions

View File

@@ -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>_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)
}

View File

@@ -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")
}
}

View File

@@ -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', () => {

View File

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

View File

@@ -77,8 +77,8 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
onFormat,
onToggleResultPanelVisibility,
onAIAction,
}) => (
<div className={isV2Ui ? 'gn-v2-query-toolbar' : undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
}) => {
const selects = (
<div
className={isV2Ui ? 'gn-v2-query-toolbar-selects' : undefined}
style={{ display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}
@@ -123,8 +123,11 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
onCommitModeChange={onCommitModeChange}
onAutoCommitDelayMsChange={onAutoCommitDelayMsChange}
/>
{pendingTransactionToolbar}
{!isV2Ui && pendingTransactionToolbar}
</div>
);
const actions = (
<div
className={isV2Ui ? 'gn-v2-query-toolbar-actions' : undefined}
style={{ display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}
@@ -198,7 +201,33 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
<Button className={isV2Ui ? 'gn-v2-query-toolbar-ai-action' : undefined} icon={<RobotOutlined />} style={{ color: '#818cf8' }}>AI</Button>
</Dropdown>
</div>
</div>
);
);
if (!isV2Ui) {
return (
<div className={undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
{selects}
{actions}
</div>
);
}
return (
<div className="gn-v2-query-toolbar" style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0 }}>
<div
className="gn-v2-query-toolbar-main"
style={{ display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}
>
{selects}
{actions}
</div>
{pendingTransactionToolbar && (
<div className="gn-v2-query-toolbar-transaction-row">
{pendingTransactionToolbar}
</div>
)}
</div>
);
};
export default QueryEditorToolbar;

View File

@@ -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 {

View File

@@ -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 {