mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 02:19:58 +08:00
🐛 fix(sql-editor): 修复 Oracle 事务结束并补充 Redis 拓扑提示
- SQL 编辑器:Oracle 托管事务优先使用 transaction provider 完成提交和回滚 - Redis:拆分 Key 浏览工具栏并展示 Cluster/Sentinel 拓扑上下文 - 测试:补充 Oracle 事务结束和 Redis 拓扑头部回归用例
This commit is contained in:
@@ -129,10 +129,31 @@ const flushEffects = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const collectRenderedText = (node: any): string => {
|
||||
if (node == null || typeof node === 'boolean') return '';
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(collectRenderedText).join('');
|
||||
if (Array.isArray(node.children)) return node.children.map(collectRenderedText).join('');
|
||||
return '';
|
||||
};
|
||||
|
||||
describe('RedisViewer tree interactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
antdState.treeProps = null;
|
||||
storeState.connections = [
|
||||
{
|
||||
id: 'redis-1',
|
||||
name: 'redis',
|
||||
config: {
|
||||
type: 'redis',
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
password: '',
|
||||
database: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
redisBackend.RedisScanKeys.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -190,4 +211,35 @@ describe('RedisViewer tree interactions', () => {
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('shows Redis Cluster topology context in the key explorer header', async () => {
|
||||
storeState.connections = [
|
||||
{
|
||||
id: 'redis-1',
|
||||
name: 'redis-cluster',
|
||||
config: {
|
||||
type: 'redis',
|
||||
host: '10.0.0.1',
|
||||
port: 6379,
|
||||
hosts: ['10.0.0.2:6379', '10.0.0.3:6379'],
|
||||
topology: 'cluster',
|
||||
password: '',
|
||||
database: '',
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<RedisViewer connectionId="redis-1" redisDB={2} />);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const renderedText = collectRenderedText(renderer!.toJSON());
|
||||
expect(renderedText).toContain('db2');
|
||||
expect(renderedText).toContain('Cluster');
|
||||
expect(renderedText).toContain('3 节点');
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
|
||||
import Editor from './MonacoEditor';
|
||||
@@ -30,8 +30,7 @@ import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
|
||||
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
|
||||
|
||||
const { Search } = Input;
|
||||
import RedisViewerKeyToolbar from './RedisViewerKeyToolbar';
|
||||
|
||||
const REDIS_TREE_KEY_TYPE_WIDTH = 92;
|
||||
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
|
||||
@@ -1849,51 +1848,29 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
{/* Left: Key List */}
|
||||
<div ref={leftPanelRef} className={isV2Ui ? 'gn-v2-redis-sidebar' : undefined} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-header' : undefined} style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>Key Explorer</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: workbenchTheme.textPrimary, marginTop: 4 }}>db{redisDB}</div>
|
||||
</div>
|
||||
<Tag style={mutedPillTagStyle}>{keys.length} Keys</Tag>
|
||||
</div>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Radio.Group
|
||||
value={searchMode}
|
||||
onChange={handleSearchModeChange}
|
||||
buttonStyle="solid"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Radio.Button value="fuzzy">模糊</Radio.Button>
|
||||
<Radio.Button value="exact">精确</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Search
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key(模糊匹配)'}
|
||||
value={searchInput}
|
||||
onChange={handleSearchInputChange}
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Space wrap size={8}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}>刷新</Button>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>新建</Button>
|
||||
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}>全选全部</Button>
|
||||
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}>取消全选</Button>
|
||||
</Space>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedKeys.length} 个 Key?`}
|
||||
onConfirm={() => handleDeleteKeys(selectedKeys)}
|
||||
disabled={selectedKeys.length === 0}
|
||||
>
|
||||
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />} disabled={selectedKeys.length === 0}>
|
||||
删除选中({selectedKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<RedisViewerKeyToolbar
|
||||
isV2Ui={isV2Ui}
|
||||
redisDB={redisDB}
|
||||
connection={connection}
|
||||
keyCount={keys.length}
|
||||
selectedKeyCount={selectedKeys.length}
|
||||
searchMode={searchMode}
|
||||
searchInput={searchInput}
|
||||
mutedPillTagStyle={mutedPillTagStyle}
|
||||
actionButtonStyle={actionButtonStyle}
|
||||
primaryActionButtonStyle={primaryActionButtonStyle}
|
||||
dangerActionButtonStyle={dangerActionButtonStyle}
|
||||
textMutedColor={workbenchTheme.textMuted}
|
||||
textPrimaryColor={workbenchTheme.textPrimary}
|
||||
onSearchModeChange={handleSearchModeChange}
|
||||
onSearchInputChange={handleSearchInputChange}
|
||||
onSearch={handleSearch}
|
||||
onRefresh={handleRefresh}
|
||||
onCreateKey={() => setNewKeyModalOpen(true)}
|
||||
onSelectAllLoadedKeys={handleSelectAllLoadedKeys}
|
||||
onClearAllSelectedKeys={handleClearAllSelectedKeys}
|
||||
onDeleteSelectedKeys={() => handleDeleteKeys(selectedKeys)}
|
||||
/>
|
||||
</div>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-tree-card' : undefined} style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
|
||||
{isLargeKeyspace && (
|
||||
|
||||
152
frontend/src/components/RedisViewerKeyToolbar.tsx
Normal file
152
frontend/src/components/RedisViewerKeyToolbar.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Button, Input, Popconfirm, Radio, Space, Tag } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SavedConnection } from '../types';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import type { RedisSearchMode } from '../utils/redisSearchPattern';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const normalizeText = (value: unknown): string => String(value || '').trim();
|
||||
|
||||
const normalizeRedisTopology = (connection?: SavedConnection): 'single' | 'cluster' | 'sentinel' => {
|
||||
const topology = normalizeText(connection?.config?.topology).toLowerCase();
|
||||
if (topology === 'sentinel') return 'sentinel';
|
||||
if (topology === 'cluster') return 'cluster';
|
||||
const extraHosts = Array.isArray(connection?.config?.hosts) ? connection.config.hosts.filter(Boolean) : [];
|
||||
return extraHosts.length > 0 ? 'cluster' : 'single';
|
||||
};
|
||||
|
||||
const buildRedisSeedAddresses = (connection?: SavedConnection): string[] => {
|
||||
if (!connection) return [];
|
||||
const config = connection.config || {};
|
||||
const port = Number.isFinite(Number(config.port)) ? Number(config.port) : 6379;
|
||||
const primary = normalizeText(config.host) ? `${normalizeText(config.host)}:${port}` : '';
|
||||
const extraHosts = Array.isArray(config.hosts)
|
||||
? config.hosts.map((host) => normalizeText(host)).filter(Boolean)
|
||||
: [];
|
||||
return [primary, ...extraHosts].filter(Boolean);
|
||||
};
|
||||
|
||||
const getRedisTopologyLabel = (topology: 'single' | 'cluster' | 'sentinel'): string => {
|
||||
if (topology === 'cluster') return 'Cluster';
|
||||
if (topology === 'sentinel') return 'Sentinel';
|
||||
return '单机';
|
||||
};
|
||||
|
||||
type RedisViewerKeyToolbarProps = {
|
||||
isV2Ui: boolean;
|
||||
redisDB: number;
|
||||
connection?: SavedConnection;
|
||||
keyCount: number;
|
||||
selectedKeyCount: number;
|
||||
searchMode: RedisSearchMode;
|
||||
searchInput: string;
|
||||
mutedPillTagStyle: React.CSSProperties;
|
||||
actionButtonStyle: React.CSSProperties;
|
||||
primaryActionButtonStyle: React.CSSProperties;
|
||||
dangerActionButtonStyle: React.CSSProperties;
|
||||
textMutedColor: string;
|
||||
textPrimaryColor: string;
|
||||
onSearchModeChange: (event: RadioChangeEvent) => void;
|
||||
onSearchInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSearch: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateKey: () => void;
|
||||
onSelectAllLoadedKeys: () => void;
|
||||
onClearAllSelectedKeys: () => void;
|
||||
onDeleteSelectedKeys: () => void;
|
||||
};
|
||||
|
||||
const RedisViewerKeyToolbar: React.FC<RedisViewerKeyToolbarProps> = ({
|
||||
isV2Ui,
|
||||
redisDB,
|
||||
connection,
|
||||
keyCount,
|
||||
selectedKeyCount,
|
||||
searchMode,
|
||||
searchInput,
|
||||
mutedPillTagStyle,
|
||||
actionButtonStyle,
|
||||
primaryActionButtonStyle,
|
||||
dangerActionButtonStyle,
|
||||
textMutedColor,
|
||||
textPrimaryColor,
|
||||
onSearchModeChange,
|
||||
onSearchInputChange,
|
||||
onSearch,
|
||||
onRefresh,
|
||||
onCreateKey,
|
||||
onSelectAllLoadedKeys,
|
||||
onClearAllSelectedKeys,
|
||||
onDeleteSelectedKeys,
|
||||
}) => {
|
||||
const topology = normalizeRedisTopology(connection);
|
||||
const seedAddresses = buildRedisSeedAddresses(connection);
|
||||
const sentinelMaster = topology === 'sentinel'
|
||||
? normalizeText(connection?.config?.redisSentinelMaster)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: textMutedColor, fontWeight: 600 }}>Key Explorer</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: textPrimaryColor }}>db{redisDB}</div>
|
||||
<Tag style={mutedPillTagStyle}>{getRedisTopologyLabel(topology)}</Tag>
|
||||
{topology !== 'single' && (
|
||||
<Tag style={mutedPillTagStyle}>{seedAddresses.length || 1} 节点</Tag>
|
||||
)}
|
||||
{sentinelMaster && (
|
||||
<Tag style={mutedPillTagStyle}>master: {sentinelMaster}</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Tag style={mutedPillTagStyle}>{keyCount} Keys</Tag>
|
||||
</div>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Radio.Group
|
||||
value={searchMode}
|
||||
onChange={onSearchModeChange}
|
||||
buttonStyle="solid"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Radio.Button value="fuzzy">模糊</Radio.Button>
|
||||
<Radio.Button value="exact">精确</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Search
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key(模糊匹配)'}
|
||||
value={searchInput}
|
||||
onChange={onSearchInputChange}
|
||||
onSearch={onSearch}
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Space wrap size={8}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={onRefresh}>刷新</Button>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={onCreateKey}>新建</Button>
|
||||
<Button size="small" style={primaryActionButtonStyle} onClick={onSelectAllLoadedKeys} disabled={keyCount === 0}>全选全部</Button>
|
||||
<Button size="small" style={actionButtonStyle} onClick={onClearAllSelectedKeys} disabled={selectedKeyCount === 0}>取消全选</Button>
|
||||
</Space>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedKeyCount} 个 Key?`}
|
||||
onConfirm={onDeleteSelectedKeys}
|
||||
disabled={selectedKeyCount === 0}
|
||||
>
|
||||
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />} disabled={selectedKeyCount === 0}>
|
||||
删除选中({selectedKeyCount})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedisViewerKeyToolbar;
|
||||
@@ -683,6 +683,89 @@ func TestDBQueryMultiTransactionalUsesImplicitSessionTransactionForOracle(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryMultiTransactionalOraclePrefersTransactionProviderForFinish(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
finish func(*App, string) connection.QueryResult
|
||||
wantCommitCalls int
|
||||
wantRollbackCalls int
|
||||
}{
|
||||
{
|
||||
name: "commit",
|
||||
finish: func(app *App, transactionID string) connection.QueryResult {
|
||||
return app.DBCommitTransaction(transactionID)
|
||||
},
|
||||
wantCommitCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "rollback",
|
||||
finish: func(app *App, transactionID string) connection.QueryResult {
|
||||
return app.DBRollbackTransaction(transactionID)
|
||||
},
|
||||
wantRollbackCalls: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt := "UPDATE users SET name = 'new' WHERE id = 1"
|
||||
fakeDB := &fakeTransactionalDB{
|
||||
fakeBatchWriteDB: fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{
|
||||
stmt: 1,
|
||||
},
|
||||
execErr: map[string]error{
|
||||
"COMMIT": errors.New("oracle commit rows affected unavailable"),
|
||||
"ROLLBACK": errors.New("oracle rollback rows affected unavailable"),
|
||||
},
|
||||
},
|
||||
}
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return fakeDB, nil
|
||||
}
|
||||
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
config := connection.ConnectionConfig{Type: "oracle", Host: "127.0.0.1", Port: 1521, User: "app"}
|
||||
|
||||
result := app.DBQueryMultiTransactional(config, "ORCLPDB1", stmt, "oracle-provider-finish-"+tt.name)
|
||||
if !result.Success {
|
||||
t.Fatalf("expected Oracle transactional query success, got failure: %s", result.Message)
|
||||
}
|
||||
if result.TransactionID == "" || !result.TransactionPending {
|
||||
t.Fatalf("expected pending transaction metadata, got id=%q pending=%v", result.TransactionID, result.TransactionPending)
|
||||
}
|
||||
if fakeDB.session != nil {
|
||||
t.Fatal("expected Oracle to use transaction provider instead of plain session provider")
|
||||
}
|
||||
if fakeDB.txSession == nil {
|
||||
t.Fatal("expected Oracle to open a transaction provider session")
|
||||
}
|
||||
|
||||
finishResult := tt.finish(app, result.TransactionID)
|
||||
if !finishResult.Success {
|
||||
t.Fatalf("expected Oracle transaction %s success through transaction provider, got failure: %s", tt.name, finishResult.Message)
|
||||
}
|
||||
if fakeDB.txSession.commitCalls != tt.wantCommitCalls {
|
||||
t.Fatalf("expected commitCalls=%d, got %d", tt.wantCommitCalls, fakeDB.txSession.commitCalls)
|
||||
}
|
||||
if fakeDB.txSession.rollbackCalls != tt.wantRollbackCalls {
|
||||
t.Fatalf("expected rollbackCalls=%d, got %d", tt.wantRollbackCalls, fakeDB.txSession.rollbackCalls)
|
||||
}
|
||||
if !fakeDB.txSession.closed {
|
||||
t.Fatal("expected transaction provider session to close after finish")
|
||||
}
|
||||
for _, query := range fakeDB.execQueries {
|
||||
if query == "COMMIT" || query == "ROLLBACK" {
|
||||
t.Fatalf("expected finish to avoid plain ExecContext(%q), got exec queries %#v", query, fakeDB.execQueries)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryMultiTransactionalUsesOracleImplicitSessionForOceanBaseOracleProtocol(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc
|
||||
|
||||
@@ -68,21 +68,7 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
transactionCancel context.CancelFunc
|
||||
startTextTransaction bool
|
||||
)
|
||||
if implicitTextTransaction {
|
||||
provider, ok := dbInst.(db.SessionExecerProvider)
|
||||
if !ok {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", transactionDBType),
|
||||
QueryID: queryID,
|
||||
}
|
||||
}
|
||||
sessionExecer, err = provider.OpenSessionExecer(ctx)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMultiTransactional 打开隐式事务会话失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
} else if provider, ok := dbInst.(db.TransactionExecerProvider); ok {
|
||||
if provider, ok := dbInst.(db.TransactionExecerProvider); ok {
|
||||
// database/sql rolls back a BeginTx transaction when its context is cancelled.
|
||||
// SQL editor transactions must outlive the execution RPC and be ended only by
|
||||
// explicit commit, rollback, or shutdown cleanup.
|
||||
@@ -96,6 +82,20 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
}
|
||||
sessionExecer = transactionExecer
|
||||
transactor = transactionExecer
|
||||
} else if implicitTextTransaction {
|
||||
provider, ok := dbInst.(db.SessionExecerProvider)
|
||||
if !ok {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", transactionDBType),
|
||||
QueryID: queryID,
|
||||
}
|
||||
}
|
||||
sessionExecer, err = provider.OpenSessionExecer(ctx)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMultiTransactional 打开隐式事务会话失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
} else {
|
||||
if !hasTextTransaction {
|
||||
return connection.QueryResult{
|
||||
|
||||
Reference in New Issue
Block a user