🐛 fix(oceanbase): 修复 Oracle 协议保存与连接链路

- 测试连接统一走 RPC 配置构造,确保 OceanBase Oracle 协议生效

- 保存连接时同步写入 oceanBaseProtocol 与 protocol 参数

- 编辑回显支持从显式字段、连接参数和 URI 恢复协议

- 双击连接时清理旧树缓存,避免复用 MySQL 协议子节点

- 补充 OceanBase 协议解析与缓存 key 隔离测试
This commit is contained in:
Syngnat
2026-04-30 17:27:17 +08:00
parent 5f9adcac37
commit 3c68325132
14 changed files with 289 additions and 31 deletions

View File

@@ -62,6 +62,7 @@ import {
import { resolveConnectionSecretDraft } from "../utils/connectionSecretDraft";
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { CUSTOM_CONNECTION_DRIVER_HELP } from "../utils/driverImportGuidance";
import {
applyNoAutoCapAttributes,
@@ -121,6 +122,14 @@ const OCEANBASE_PROTOCOL_OPTIONS: Array<{
{ value: "mysql", label: "MySQL" },
{ value: "oracle", label: "Oracle" },
];
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
"protocol",
"oceanBaseProtocol",
"oceanbaseProtocol",
"tenantMode",
"compatMode",
"mode",
];
const normalizeClickHouseProtocolValue = (
value: unknown,
@@ -140,6 +149,47 @@ const normalizeOceanBaseProtocolValue = (
.toLowerCase();
return text === "oracle" ? "oracle" : "mysql";
};
const resolveOceanBaseProtocolValue = (
value: unknown,
): OceanBaseProtocolChoice | undefined => {
const text = String(value || "")
.trim()
.toLowerCase();
if (!text) return undefined;
return ["oracle", "oracle-mode", "oracle_mode", "oboracle"].includes(text)
? "oracle"
: "mysql";
};
const resolveOceanBaseProtocolFromQueryText = (
value: unknown,
): OceanBaseProtocolChoice | undefined => {
let text = String(value || "").trim();
if (!text) return undefined;
const queryIndex = text.indexOf("?");
if (queryIndex >= 0) {
text = text.slice(queryIndex + 1);
}
const hashIndex = text.indexOf("#");
if (hashIndex >= 0) {
text = text.slice(0, hashIndex);
}
const params = new URLSearchParams(text.replace(/^[?&]+/, ""));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
const protocol = resolveOceanBaseProtocolValue(params.get(key));
if (protocol) return protocol;
}
return undefined;
};
const resolveOceanBaseProtocolForConfig = (
config: Partial<ConnectionConfig>,
): OceanBaseProtocolChoice => {
return (
resolveOceanBaseProtocolValue(config.oceanBaseProtocol) ||
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
"mysql"
);
};
type ConnectionSecretKey =
| "primaryPassword"
| "sshPassword"
@@ -1125,6 +1175,18 @@ const ConnectionModal: React.FC<{
return cloned.toString().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
};
const normalizeOceanBaseConnectionParamsText = (
rawParams: unknown,
selectedProtocol: OceanBaseProtocolChoice,
) => {
const params = new URLSearchParams(normalizeConnectionParamsText(rawParams));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
params.delete(key);
}
params.set("protocol", selectedProtocol);
return params.toString().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
};
const mergeConnectionParams = (
params: URLSearchParams,
rawParams: unknown,
@@ -2260,7 +2322,7 @@ const ConnectionModal: React.FC<{
: "auto",
oceanBaseProtocol:
configType === "oceanbase"
? normalizeOceanBaseProtocolValue(config.oceanBaseProtocol)
? resolveOceanBaseProtocolForConfig(config)
: "mysql",
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
@@ -2780,12 +2842,14 @@ const ConnectionModal: React.FC<{
// Use different API for Redis / JVM
const isRedisType = values.type === "redis";
const isJVMType = values.type === "jvm";
const dbTestConfig =
!isRedisType && !isJVMType ? buildRpcConnectionConfig(config as any) : config;
const res = await withClientTimeout(
isJVMType
? TestJVMConnection(config as any)
: isRedisType
? RedisConnect(config as any)
: TestConnection(config as any),
: TestConnection(dbTestConfig as any),
rpcTimeoutMs,
`连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`,
);
@@ -2798,7 +2862,7 @@ const ConnectionModal: React.FC<{
} else if (!isJVMType) {
// Other databases: fetch database list
const dbRes = await withClientTimeout(
DBGetDatabases(config as any),
DBGetDatabases(dbTestConfig as any),
rpcTimeoutMs,
`连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`,
);
@@ -3297,6 +3361,14 @@ const ConnectionModal: React.FC<{
}
const keepPassword = !forPersist || savePassword;
const normalizedConnectionParams = supportsConnectionParamsForType(type)
? type === "oceanbase"
? normalizeOceanBaseConnectionParamsText(
mergedValues.connectionParams,
selectedOceanBaseProtocol,
)
: normalizeConnectionParamsText(mergedValues.connectionParams)
: "";
return {
type: mergedValues.type,
@@ -3318,9 +3390,7 @@ const ConnectionModal: React.FC<{
httpTunnel: httpTunnelConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
connectionParams: supportsConnectionParamsForType(type)
? normalizeConnectionParamsText(mergedValues.connectionParams)
: "",
connectionParams: normalizedConnectionParams,
timeout: Number(mergedValues.timeout || 30),
redisDB: Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))

View File

@@ -91,6 +91,20 @@ type DriverStatusSnapshot = {
message?: string;
};
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
if (!conn) return '';
return JSON.stringify({
config: conn.config || {},
includeDatabases: conn.includeDatabases || [],
includeRedisDatabases: conn.includeRedisDatabases || [],
});
};
const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
const text = String(key);
return text === connectionId || text.startsWith(`${connectionId}-`);
};
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
const normalizeDriverType = (value: string): string => {
@@ -248,6 +262,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
@@ -375,6 +390,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
useEffect(() => {
const previousSignatures = connectionReloadSignaturesRef.current;
const nextSignatures: Record<string, string> = {};
const staleConnectionIds = new Set<string>();
connections.forEach((conn) => {
const signature = buildConnectionReloadSignature(conn);
nextSignatures[conn.id] = signature;
if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) {
staleConnectionIds.add(conn.id);
}
});
connectionReloadSignaturesRef.current = nextSignatures;
if (staleConnectionIds.size > 0) {
const staleIds = Array.from(staleConnectionIds);
setLoadedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setExpandedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setConnectionStates((prev) => {
const next = { ...prev };
staleIds.forEach((id) => {
Object.keys(next).forEach((key) => {
if (isConnectionTreeKey(key, id)) {
delete next[key];
}
});
});
return next;
});
staleIds.forEach((id) => {
Array.from(loadingNodesRef.current).forEach((key) => {
if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) {
loadingNodesRef.current.delete(key);
}
});
});
}
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
@@ -395,6 +451,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const existing = prevMap.get(conn.id);
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
return {
title: conn.name,
key: conn.id,
@@ -402,7 +459,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
type: 'connection',
dataRef: conn,
isLeaf: false,
children: existing?.children,
children: preserveChildren ? existing.children : undefined,
} as TreeNode;
};

View File

@@ -294,6 +294,42 @@ describe('store appearance persistence', () => {
);
});
it('normalizes OceanBase protocol when updating a saved connection', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql',
},
},
]);
useStore.getState().updateConnection({
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'protocol=oracle',
},
});
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -1423,13 +1423,31 @@ export const useStore = create<AppState>()(
jvmDiagnosticOutputs: {},
addConnection: (conn) =>
set((state) => ({ connections: [...state.connections, conn] })),
set((state) => {
const sanitized = sanitizeSavedConnection(
conn,
state.connections.length,
);
if (!sanitized) {
return { connections: state.connections };
}
return { connections: [...state.connections, sanitized] };
}),
updateConnection: (conn) =>
set((state) => ({
connections: state.connections.map((c) =>
c.id === conn.id ? conn : c,
),
})),
set((state) => {
const sanitized = sanitizeSavedConnection(
conn,
state.connections.length,
);
if (!sanitized) {
return { connections: state.connections };
}
return {
connections: state.connections.map((c) =>
c.id === conn.id ? sanitized : c,
),
};
}),
removeConnection: (id) =>
set((state) => ({
connections: state.connections.filter((c) => c.id !== id),

View File

@@ -167,13 +167,14 @@ export function buildRpcConnectionConfig(
httpTunnel: mergedHttpTunnel,
};
const rpcMerged = withOceanBaseProtocolParam(merged);
const { oceanBaseProtocol: _oceanBaseProtocol, ...rpcPayload } = rpcMerged;
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB));
const rpcConfig = new connection.ConnectionConfig({
...rpcMerged,
...rpcPayload,
type: toStringValue(rpcMerged.type),
host: toStringValue(rpcMerged.host),
port: toOptionalInteger(rpcMerged.port, toOptionalInteger(config.port, 0)) ?? 0,

View File

@@ -674,6 +674,7 @@ export namespace connection {
redisDB?: number;
uri?: string;
clickHouseProtocol?: string;
oceanBaseProtocol?: string;
hosts?: string[];
topology?: string;
mysqlReplicaUser?: string;
@@ -718,6 +719,7 @@ export namespace connection {
this.redisDB = source["redisDB"];
this.uri = source["uri"];
this.clickHouseProtocol = source["clickHouseProtocol"];
this.oceanBaseProtocol = source["oceanBaseProtocol"];
this.hosts = source["hosts"];
this.topology = source["topology"];
this.mysqlReplicaUser = source["mysqlReplicaUser"];

View File

@@ -180,7 +180,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
normalized.ID = ""
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
if normalized.Type == "oceanbase" {
normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCache(normalized.ConnectionParams)
protocol := resolveOceanBaseProtocolForApp(normalized)
normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCacheWithProtocol(normalized.ConnectionParams, protocol)
normalized.OceanBaseProtocol = ""
}
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
normalized.Timeout = 0

View File

@@ -139,6 +139,24 @@ func TestGetCacheKey_KeepOceanBaseProtocolIsolation(t *testing.T) {
}
}
func TestGetCacheKey_KeepOceanBaseExplicitProtocolIsolation(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "sys@oracle001",
Database: "ORCL",
}
modified := base
modified.OceanBaseProtocol = "oracle"
left := getCacheKey(base)
right := getCacheKey(modified)
if left == right {
t.Fatalf("expected different cache key for explicit OceanBase Oracle protocol")
}
}
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToMySQL(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
@@ -157,6 +175,24 @@ func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToMySQL(t *testing.T)
}
}
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToExplicitMySQL(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "root@test",
Database: "app",
}
modified := base
modified.OceanBaseProtocol = "mysql"
left := getCacheKey(base)
right := getCacheKey(modified)
if left != right {
t.Fatalf("expected default OceanBase protocol to equal explicit mysql, got %s vs %s", left, right)
}
}
func TestGetCacheKey_OceanBaseProtocolParamWinsOverAliases(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",

View File

@@ -54,9 +54,9 @@ func TestNormalizeRunConfig_OceanBaseOracleKeepsServiceName(t *testing.T) {
t.Parallel()
config := connection.ConnectionConfig{
Type: "oceanbase",
Database: "OBORCL",
ConnectionParams: "protocol=oracle",
Type: "oceanbase",
Database: "OBORCL",
OceanBaseProtocol: "oracle",
}
runConfig := normalizeRunConfig(config, "SYS")
@@ -69,8 +69,8 @@ func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *te
t.Parallel()
schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{
Type: "oceanbase",
ConnectionParams: "protocol=oracle",
Type: "oceanbase",
OceanBaseProtocol: "oracle",
}, "SYS", "ORDERS")
if schema != "SYS" || table != "ORDERS" {

View File

@@ -90,8 +90,8 @@ func TestResolveDDLDBType_OceanBaseOracleProtocol(t *testing.T) {
t.Parallel()
cfg := connection.ConnectionConfig{
Type: "oceanbase",
ConnectionParams: "protocol=oracle",
Type: "oceanbase",
OceanBaseProtocol: "oracle",
}
if got := resolveDDLDBType(cfg); got != "oracle" {
t.Fatalf("expected OceanBase Oracle protocol to use oracle DDL dialect, got %q", got)

View File

@@ -11,11 +11,29 @@ func normalizeOceanBaseProtocolForApp(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "oracle", "oracle-mode", "oracle_mode", "oboracle":
return "oracle"
case "mysql", "mysql-compatible", "mysql_compatible", "mysql-mode", "mysql_mode":
return "mysql"
default:
return "mysql"
}
}
func resolveOceanBaseProtocolForApp(config connection.ConnectionConfig) string {
if !strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") {
return ""
}
if explicit := strings.TrimSpace(config.OceanBaseProtocol); explicit != "" {
return normalizeOceanBaseProtocolForApp(explicit)
}
if protocol := resolveOceanBaseProtocolParam(config.ConnectionParams); protocol != "" {
return protocol
}
if protocol := resolveOceanBaseProtocolParam(config.URI); protocol != "" {
return protocol
}
return "mysql"
}
func resolveOceanBaseProtocolParam(raw string) string {
text := strings.TrimSpace(raw)
if text == "" {
@@ -61,15 +79,19 @@ func normalizeOceanBaseConnectionParamsForCache(raw string) string {
return values.Encode()
}
func isOceanBaseOracleProtocol(config connection.ConnectionConfig) bool {
if !strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") {
return false
func normalizeOceanBaseConnectionParamsForCacheWithProtocol(raw string, protocol string) string {
normalized := normalizeOceanBaseConnectionParamsForCache(raw)
if !strings.EqualFold(protocol, "oracle") {
return normalized
}
if protocol := resolveOceanBaseProtocolParam(config.ConnectionParams); protocol != "" {
return protocol == "oracle"
values, err := url.ParseQuery(strings.TrimLeft(strings.TrimSpace(normalized), "?&"))
if err != nil {
values = url.Values{}
}
if protocol := resolveOceanBaseProtocolParam(config.URI); protocol != "" {
return protocol == "oracle"
}
return false
values.Set("protocol", "oracle")
return values.Encode()
}
func isOceanBaseOracleProtocol(config connection.ConnectionConfig) bool {
return resolveOceanBaseProtocolForApp(config) == "oracle"
}

View File

@@ -104,6 +104,7 @@ type ConnectionConfig struct {
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native
OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // mysql | oracle
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica | cluster
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user

View File

@@ -171,6 +171,9 @@ func resolveOceanBaseProtocolFromValues(values url.Values) string {
}
func resolveOceanBaseProtocol(config connection.ConnectionConfig) string {
if explicit := strings.TrimSpace(config.OceanBaseProtocol); explicit != "" {
return normalizeOceanBaseProtocol(explicit)
}
if protocol := resolveOceanBaseProtocolFromValues(connectionParamsFromText(config.ConnectionParams)); protocol != "" {
return protocol
}
@@ -213,6 +216,7 @@ func stripOceanBaseProtocolURI(raw string) string {
func withoutOceanBaseProtocolParams(config connection.ConnectionConfig) connection.ConnectionConfig {
next := config
next.OceanBaseProtocol = ""
next.ConnectionParams = stripOceanBaseProtocolParams(config.ConnectionParams)
next.URI = stripOceanBaseProtocolURI(config.URI)
return next

View File

@@ -56,6 +56,15 @@ func TestResolveOceanBaseProtocol(t *testing.T) {
},
want: oceanBaseProtocolMySQL,
},
{
name: "explicit config protocol wins over params",
config: connection.ConnectionConfig{
Type: "oceanbase",
OceanBaseProtocol: "oracle",
ConnectionParams: "protocol=mysql",
},
want: oceanBaseProtocolOracle,
},
{
name: "protocol key wins over compatibility aliases",
config: connection.ConnectionConfig{