♻️ refactor(oceanbase): 完善双协议连接链路

- 抽象 OceanBase 协议解析与运行态参数注入
- 复用 OracleDB 实现 OceanBase Oracle 租户连接能力
- 调整 DDL、schema、SQL 方言和数据源能力判断
- 补充协议优先级、缓存隔离和 RPC 参数测试
- 支持按指定 driver 自动生成 agent revision
This commit is contained in:
Syngnat
2026-04-30 15:05:05 +08:00
parent 98c62fd6bd
commit d2dad75167
31 changed files with 1081 additions and 63 deletions

View File

@@ -97,6 +97,7 @@ type ChoiceCardOption = {
description?: string;
};
type ClickHouseProtocolChoice = "auto" | "http" | "native";
type OceanBaseProtocolChoice = "mysql" | "oracle";
const MAX_URI_LENGTH = 4096;
const MAX_CONNECTION_PARAMS_LENGTH = 4096;
const MAX_URI_HOSTS = 32;
@@ -113,6 +114,13 @@ const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{
{ value: "http", label: "HTTP" },
{ value: "native", label: "Native" },
];
const OCEANBASE_PROTOCOL_OPTIONS: Array<{
value: OceanBaseProtocolChoice;
label: string;
}> = [
{ value: "mysql", label: "MySQL" },
{ value: "oracle", label: "Oracle" },
];
const normalizeClickHouseProtocolValue = (
value: unknown,
@@ -124,6 +132,14 @@ const normalizeClickHouseProtocolValue = (
if (text === "native" || text === "tcp") return "native";
return "auto";
};
const normalizeOceanBaseProtocolValue = (
value: unknown,
): OceanBaseProtocolChoice => {
const text = String(value || "")
.trim()
.toLowerCase();
return text === "oracle" ? "oracle" : "mysql";
};
type ConnectionSecretKey =
| "primaryPassword"
| "sshPassword"
@@ -366,6 +382,9 @@ const ConnectionModal: React.FC<{
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
const redisTopology = Form.useWatch("redisTopology", form) || "single";
const oceanBaseProtocol = normalizeOceanBaseProtocolValue(
Form.useWatch("oceanBaseProtocol", form),
);
const sslMode = Form.useWatch("sslMode", form) || "preferred";
const proxyType = Form.useWatch("proxyType", form) || "socks5";
const customDriver = Form.useWatch("driver", form) || "";
@@ -391,12 +410,15 @@ const ConnectionModal: React.FC<{
}),
[jvmAllowedModes, jvmPreferredMode],
);
const isMySQLLike = isMySQLCompatibleType(dbType);
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
const supportsConnectionParams = supportsConnectionParamsForType(dbType);
const isSSLType = supportsSSLForType(dbType);
const sslHintText = isMySQLLike
? "当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL本地自签证书场景可先用 Preferred 或 Skip Verify。"
: dbType === "dameng"
: isOceanBaseOracle
? "OceanBase Oracle 租户使用 Oracle 协议连接SSL 参数按 Oracle 驱动规则传递。"
: dbType === "dameng"
? "达梦驱动启用 SSL 需要客户端证书与私钥路径sslCertPath / sslKeyPath。"
: dbType === "sqlserver"
? "SQL Server 推荐在生产环境使用 Required并关闭 TrustServerCertificate。"
@@ -1320,6 +1342,17 @@ const ConnectionModal: React.FC<{
)
.trim()
.toLowerCase();
const parsedOceanBaseProtocol =
type === "oceanbase"
? normalizeOceanBaseProtocolValue(
parsed.params.get("protocol") ||
parsed.params.get("oceanBaseProtocol") ||
parsed.params.get("oceanbaseProtocol") ||
parsed.params.get("tenantMode") ||
parsed.params.get("compatMode") ||
parsed.params.get("mode"),
)
: undefined;
const sslMode =
tlsValue === "true"
? "required"
@@ -1336,8 +1369,13 @@ const ConnectionModal: React.FC<{
database: parsed.database || "",
useSSL: sslMode !== "disable",
sslMode,
oceanBaseProtocol: parsedOceanBaseProtocol,
mysqlTopology:
hostList.length > 1 || topology === "replica" ? "replica" : "single",
parsedOceanBaseProtocol === "oracle"
? "single"
: hostList.length > 1 || topology === "replica"
? "replica"
: "single",
mysqlReplicaHosts: hostList.slice(1),
connectionParams: serializeConnectionParams(parsed.params),
timeout:
@@ -1700,6 +1738,9 @@ const ConnectionModal: React.FC<{
const defaultPort = getDefaultPortByType(dbType);
const scheme =
dbType === "diros" ? "doris" : dbType === "oceanbase" ? "oceanbase" : "mysql";
if (dbType === "oceanbase") {
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
}
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
}
if (isFileDatabaseType(dbType)) {
@@ -1726,6 +1767,11 @@ const ConnectionModal: React.FC<{
};
const getConnectionParamsPlaceholder = () => {
if (dbType === "oceanbase") {
return oceanBaseProtocol === "oracle"
? "PREFETCH_ROWS=5000"
: "useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false";
}
if (isMySQLCompatibleType(dbType)) {
return "useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false";
}
@@ -1769,9 +1815,13 @@ const ConnectionModal: React.FC<{
: "";
if (isMySQLCompatibleType(type)) {
const selectedOceanBaseProtocol =
type === "oceanbase"
? normalizeOceanBaseProtocolValue(values.oceanBaseProtocol)
: "mysql";
const primary = toAddress(host, port, defaultPort);
const replicas =
values.mysqlTopology === "replica"
selectedOceanBaseProtocol !== "oracle" && values.mysqlTopology === "replica"
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
: [];
const hosts = normalizeAddressList([primary, ...replicas], defaultPort);
@@ -1795,6 +1845,9 @@ const ConnectionModal: React.FC<{
params.set("timeout", String(timeout));
}
mergeConnectionParams(params, values.connectionParams);
if (type === "oceanbase") {
params.set("protocol", selectedOceanBaseProtocol);
}
const dbPath = database ? `/${encodeURIComponent(database)}` : "/";
const query = params.toString();
const scheme =
@@ -2205,6 +2258,10 @@ const ConnectionModal: React.FC<{
configType === "clickhouse"
? normalizeClickHouseProtocolValue(config.clickHouseProtocol)
: "auto",
oceanBaseProtocol:
configType === "oceanbase"
? normalizeOceanBaseProtocolValue(config.oceanBaseProtocol)
: "mysql",
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
useSSL: !!config.useSSL,
@@ -2996,6 +3053,10 @@ const ConnectionModal: React.FC<{
const type = String(mergedValues.type || "").toLowerCase();
const defaultPort = getDefaultPortByType(type);
const selectedOceanBaseProtocol =
type === "oceanbase"
? normalizeOceanBaseProtocolValue(mergedValues.oceanBaseProtocol)
: "mysql";
if (type === "clickhouse") {
const requestedProtocol = normalizeClickHouseProtocolValue(
mergedValues.clickHouseProtocol,
@@ -3095,7 +3156,7 @@ const ConnectionModal: React.FC<{
const savePassword =
type === "mongodb" ? mergedValues.savePassword !== false : true;
if (isMySQLCompatibleType(type)) {
if (isMySQLCompatibleType(type) && selectedOceanBaseProtocol !== "oracle") {
const replicas =
mergedValues.mysqlTopology === "replica"
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
@@ -3269,6 +3330,8 @@ const ConnectionModal: React.FC<{
type === "clickhouse"
? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol)
: undefined,
oceanBaseProtocol:
type === "oceanbase" ? selectedOceanBaseProtocol : undefined,
hosts: hosts,
topology: topology,
mysqlReplicaUser: mysqlReplicaUser,
@@ -3299,6 +3362,7 @@ const ConnectionModal: React.FC<{
form.setFieldsValue({
type: type,
clickHouseProtocol: type === "clickhouse" ? "auto" : undefined,
oceanBaseProtocol: type === "oceanbase" ? "mysql" : undefined,
});
const defaultPort = getDefaultPortByType(type);
@@ -3639,6 +3703,8 @@ const ConnectionModal: React.FC<{
return "单机 / 集群";
case "mongodb":
return "单机 / 副本集";
case "oceanbase":
return "MySQL / Oracle 租户";
case "sqlite":
case "duckdb":
return "本地文件连接";
@@ -4631,6 +4697,28 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "oceanbase" &&
renderConfigSectionCard({
sectionKey: "oceanBaseProtocol",
icon: <ClusterOutlined />,
children: (
<Form.Item
name="oceanBaseProtocol"
label="OceanBase 协议"
help="MySQL 租户选择 MySQLOracle 租户选择 Oracle。该选择会同时影响连接测试、浏览表结构和 SQL 方言。"
style={{ marginBottom: 0 }}
>
<Select
options={OCEANBASE_PROTOCOL_OPTIONS}
onChange={() => {
form.setFieldsValue({ mysqlTopology: "single" });
clearConnectionTestResultForChoice();
}}
/>
</Form.Item>
),
})}
{(dbType === "postgres" ||
dbType === "kingbase" ||
dbType === "highgo" ||
@@ -4651,20 +4739,26 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "oracle" &&
{(dbType === "oracle" || isOceanBaseOracle) &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="database"
label="服务名 (Service Name)"
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name)" : "服务名 (Service Name)"}
rules={[
createUriAwareRequiredRule(
"请输入 Oracle 服务名(例如 ORCLPDB1",
isOceanBaseOracle
? "请输入 OceanBase Oracle 服务名"
: "请输入 Oracle 服务名(例如 ORCLPDB1",
),
]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
help={
isOceanBaseOracle
? "Oracle 租户必须填写监听器注册的 SERVICE_NAME用户名仍按 OceanBase 租户格式填写。"
: "请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
}
style={{ marginBottom: 0 }}
>
<Input
@@ -6064,6 +6158,7 @@ const ConnectionModal: React.FC<{
timeout: 30,
uri: "",
connectionParams: "",
oceanBaseProtocol: "mysql",
mysqlTopology: "single",
redisTopology: "single",
mongoTopology: "single",
@@ -6112,7 +6207,8 @@ const ConnectionModal: React.FC<{
if (
changed.uri !== undefined ||
changed.connectionParams !== undefined ||
changed.type !== undefined
changed.type !== undefined ||
changed.oceanBaseProtocol !== undefined
) {
setUriFeedback(null);
}

View File

@@ -47,6 +47,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;

View File

@@ -826,6 +826,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const activeDialect = resolveSqlDialect(
String(activeConnection?.config?.type || ''),
String(activeConnection?.config?.driver || ''),
{ oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol },
);
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
@@ -1612,7 +1613,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const rpcConfig = buildRpcConnectionConfig(config) as any;
const dbType = String(rpcConfig.type || 'mysql');
const driver = String((config as any).driver || '');
const normalizedDbType = String(resolveSqlDialect(dbType, driver)).trim().toLowerCase();
const normalizedDbType = String(resolveSqlDialect(dbType, driver, {
oceanBaseProtocol: (config as any).oceanBaseProtocol,
})).trim().toLowerCase();
const normalizedRawSQL = String(rawSQL || '').replace(//g, ';');
// MongoDB 仍走逐条执行的旧路径

View File

@@ -578,6 +578,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
@@ -2621,6 +2622,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
conn?.config?.database,
overrideDatabase,
clearDatabase,
conn?.config?.oceanBaseProtocol,
),
});
};

View File

@@ -862,7 +862,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const conn = connections.find(c => c.id === tab.connectionId);
const rawType = String(conn?.config?.type || '').trim();
if (!rawType) return '';
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''));
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''), {
oceanBaseProtocol: conn?.config?.oceanBaseProtocol,
});
};
const generateTriggerTemplate = (): string => {

View File

@@ -52,7 +52,7 @@ const formatRows = (count: number): string => {
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
@@ -61,6 +61,7 @@ const getMetadataDialect = (connType: string, driver?: string): string => {
if (d === 'opengauss' || d === 'open_gauss' || d === 'open-gauss') return 'opengauss';
return d;
}
if (type === 'oceanbase' && String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
@@ -183,8 +184,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
[connection?.config?.driver, connection?.config?.type]
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver, connection?.config?.oceanBaseProtocol),
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const autoFetchVisible = useAutoFetchVisibility();

View File

@@ -33,6 +33,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;

View File

@@ -210,6 +210,90 @@ describe('store appearance persistence', () => {
);
});
it('normalizes OceanBase protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-oracle',
name: 'OceanBase Oracle',
config: {
id: 'oceanbase-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
oceanBaseProtocol: 'oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('restores OceanBase protocol from saved URI or connection params', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-uri-oracle',
name: 'OceanBase URI Oracle',
config: {
id: 'oceanbase-uri-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/OBORCL?protocol=oracle',
},
},
{
id: 'oceanbase-param-oracle',
name: 'OceanBase Param Oracle',
config: {
id: 'oceanbase-param-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'tenantMode=oracle&PREFETCH_ROWS=5000',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
expect(useStore.getState().connections[1]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('prefers OceanBase protocol query key over legacy aliases when restoring saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-conflict',
name: 'OceanBase Conflict',
config: {
id: 'oceanbase-conflict',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql&tenantMode=oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'mysql',
);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -73,6 +73,69 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
password: "",
hasPassword: false,
};
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
"protocol",
"oceanBaseProtocol",
"oceanbaseProtocol",
"tenantMode",
"compatMode",
"mode",
];
const normalizeOceanBaseProtocol = (
value: unknown,
): "mysql" | "oracle" | undefined => {
const normalized = String(value ?? "").trim().toLowerCase();
if (!normalized) {
return undefined;
}
return normalized === "oracle" ||
normalized === "oracle-mode" ||
normalized === "oracle_mode" ||
normalized === "oboracle"
? "oracle"
: "mysql";
};
const resolveOceanBaseProtocolFromQueryText = (
value: unknown,
): "mysql" | "oracle" | 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 = normalizeOceanBaseProtocol(params.get(key));
if (protocol) {
return protocol;
}
}
return undefined;
};
const resolveOceanBaseProtocol = (
raw: Record<string, unknown>,
normalizedConnectionParams: string,
normalizedUri: string,
): "mysql" | "oracle" => {
if (Object.prototype.hasOwnProperty.call(raw, "oceanBaseProtocol")) {
const explicitProtocol = normalizeOceanBaseProtocol(raw.oceanBaseProtocol);
if (explicitProtocol) {
return explicitProtocol;
}
}
return (
resolveOceanBaseProtocolFromQueryText(normalizedConnectionParams) ||
resolveOceanBaseProtocolFromQueryText(normalizedUri) ||
"mysql"
);
};
const SUPPORTED_CONNECTION_TYPES = new Set([
"mysql",
"mariadb",
@@ -546,6 +609,14 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
);
}
if (type === "oceanbase") {
safeConfig.oceanBaseProtocol = resolveOceanBaseProtocol(
raw,
safeConfig.connectionParams || "",
safeConfig.uri || "",
);
}
if (type === "custom") {
safeConfig.driver = toTrimmedString(raw.driver);
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);

View File

@@ -299,6 +299,7 @@ export interface ConnectionConfig {
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
oceanBaseProtocol?: "mysql" | "oracle"; // OceanBase tenant protocol
hosts?: string[]; // Multi-host addresses: host:port
topology?: "single" | "replica" | "cluster";
mysqlReplicaUser?: string;

View File

@@ -21,6 +21,7 @@ export type ConnectionConfigSectionKey =
| 'target'
| 'fileTarget'
| 'connectionMode'
| 'oceanBaseProtocol'
| 'mongoDiscovery'
| 'replica'
| 'service'
@@ -93,6 +94,10 @@ const CONNECTION_CONFIG_SECTION_COPY: Record<
title: '连接模式',
description: '选择单机、主从、副本集或集群等拓扑模式。',
},
oceanBaseProtocol: {
title: 'OceanBase 协议',
description: '明确选择 MySQL 租户协议或 Oracle 租户协议。',
},
mongoDiscovery: {
title: 'MongoDB 寻址',
description: '选择标准 host:port 或 mongodb+srv DNS 发现方式。',

View File

@@ -52,6 +52,64 @@ describe('buildRpcConnectionConfig', () => {
expect(result.clickHouseProtocol).toBe('http');
});
it('injects OceanBase protocol override into RPC connection params', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
database: 'ORCL',
oceanBaseProtocol: 'oracle',
} as any);
expect(result.connectionParams).toBe('protocol=oracle');
expect((result as any).oceanBaseProtocol).toBeUndefined();
});
it('keeps OceanBase URI protocol when no form override exists', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-uri',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
database: 'ORCL',
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/ORCL?protocol=oracle',
} as any);
expect(result.connectionParams).toBe('protocol=oracle');
});
it('lets OceanBase form protocol override legacy connection param aliases', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-mysql',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
database: 'app',
oceanBaseProtocol: 'mysql',
connectionParams: 'tenantMode=oracle&connectTimeout=10',
} as any);
expect(result.connectionParams).toBe('connectTimeout=10&protocol=mysql');
});
it('keeps OceanBase protocol query key ahead of compatibility aliases', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-conflict',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
database: 'app',
connectionParams: 'protocol=mysql&tenantMode=oracle',
} as any);
expect(result.connectionParams).toBe('protocol=mysql');
});
it('preserves extra connection params for RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-mysql',

View File

@@ -11,6 +11,15 @@ type ConnectionConfigInput = {
type SSHConfigInput = Record<string, any>;
type ProxyConfigInput = Record<string, any>;
type HttpTunnelConfigInput = Record<string, any>;
type OceanBaseProtocol = 'mysql' | 'oracle';
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
'protocol',
'oceanBaseProtocol',
'oceanbaseProtocol',
'tenantMode',
'compatMode',
'mode',
];
const toStringValue = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
@@ -70,6 +79,70 @@ const normalizeHttpTunnelConfig = (value: unknown): connection.HTTPTunnelConfig
});
};
const normalizeOceanBaseProtocol = (value: unknown): OceanBaseProtocol | undefined => {
const normalized = toStringValue(value).trim().toLowerCase();
if (!normalized) {
return undefined;
}
return normalized === 'oracle' || normalized === 'oracle-mode' || normalized === 'oracle_mode' || normalized === 'oboracle'
? 'oracle'
: 'mysql';
};
const resolveOceanBaseProtocolFromQueryText = (raw: unknown): OceanBaseProtocol | undefined => {
let text = toStringValue(raw).trim();
if (!text) {
return undefined;
}
const queryStart = text.indexOf('?');
if (queryStart >= 0) {
text = text.slice(queryStart + 1);
}
const hashStart = text.indexOf('#');
if (hashStart >= 0) {
text = text.slice(0, hashStart);
}
const params = new URLSearchParams(text.replace(/^[?&]+/, ''));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
const protocol = normalizeOceanBaseProtocol(params.get(key));
if (protocol) {
return protocol;
}
}
return undefined;
};
const resolveOceanBaseProtocol = (config: ConnectionConfigInput): OceanBaseProtocol => {
if (Object.prototype.hasOwnProperty.call(config, 'oceanBaseProtocol')) {
const explicitProtocol = normalizeOceanBaseProtocol(config.oceanBaseProtocol);
if (explicitProtocol) {
return explicitProtocol;
}
}
return (
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
'mysql'
);
};
const withOceanBaseProtocolParam = (config: ConnectionConfigInput): ConnectionConfigInput => {
const type = toStringValue(config.type).trim().toLowerCase();
if (type !== 'oceanbase') {
return config;
}
const selectedProtocol = resolveOceanBaseProtocol(config);
const params = new URLSearchParams(toStringValue(config.connectionParams));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
params.delete(key);
}
params.set('protocol', selectedProtocol);
return {
...config,
connectionParams: params.toString(),
};
};
export function buildRpcConnectionConfig(
config: ConnectionConfigInput,
overrides: ConnectionConfigInput = {},
@@ -93,25 +166,26 @@ export function buildRpcConnectionConfig(
proxy: mergedProxy,
httpTunnel: mergedHttpTunnel,
};
const rpcMerged = withOceanBaseProtocolParam(merged);
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
const timeout = toOptionalInteger(merged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(merged.redisDB, toOptionalInteger(config.redisDB));
const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB));
const rpcConfig = new connection.ConnectionConfig({
...merged,
type: toStringValue(merged.type),
host: toStringValue(merged.host),
port: toOptionalInteger(merged.port, toOptionalInteger(config.port, 0)) ?? 0,
user: toStringValue(merged.user),
password: toStringValue(merged.password),
database: toStringValue(merged.database),
useSSH: merged.useSSH === true,
ssh: normalizeSSHConfig(merged.ssh),
useProxy: merged.useProxy === true,
proxy: normalizeProxyConfig(merged.proxy),
useHttpTunnel: merged.useHttpTunnel === true,
httpTunnel: normalizeHttpTunnelConfig(merged.httpTunnel),
...rpcMerged,
type: toStringValue(rpcMerged.type),
host: toStringValue(rpcMerged.host),
port: toOptionalInteger(rpcMerged.port, toOptionalInteger(config.port, 0)) ?? 0,
user: toStringValue(rpcMerged.user),
password: toStringValue(rpcMerged.password),
database: toStringValue(rpcMerged.database),
useSSH: rpcMerged.useSSH === true,
ssh: normalizeSSHConfig(rpcMerged.ssh),
useProxy: rpcMerged.useProxy === true,
proxy: normalizeProxyConfig(rpcMerged.proxy),
useHttpTunnel: rpcMerged.useHttpTunnel === true,
httpTunnel: normalizeHttpTunnelConfig(rpcMerged.httpTunnel),
timeout,
redisDB,
}) as RpcConnectionConfig;
@@ -119,4 +193,3 @@ export function buildRpcConnectionConfig(
rpcConfig.id = baseId;
return rpcConfig;
}

View File

@@ -29,4 +29,15 @@ describe('dataSourceCapabilities', () => {
supportsApproximateTotalPages: false,
});
});
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
expect(getDataSourceCapabilities({
type: 'oceanbase',
oceanBaseProtocol: 'oracle',
})).toMatchObject({
type: 'oracle',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
});
});
});

View File

@@ -1,6 +1,6 @@
import type { ConnectionConfig } from '../types';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol'> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
@@ -27,6 +27,9 @@ export const resolveDataSourceType = (config: ConnectionLike): string => {
const driver = normalizeDataSourceToken(String(config.driver || ''));
return driver || 'custom';
}
if (type === 'oceanbase' && String(config.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') {
return 'oracle';
}
return type;
};

View File

@@ -11,7 +11,7 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
};
};
const normalizeSidebarConnectionDialect = (type: string, driver: string): string => {
const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBaseProtocol?: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
if (normalizedType === 'custom') {
const normalizedDriver = String(driver || '').trim().toLowerCase();
@@ -22,7 +22,9 @@ const normalizeSidebarConnectionDialect = (type: string, driver: string): string
if (normalizedDriver.includes('oracle')) return 'oracle';
return normalizedDriver;
}
if (normalizedType === 'oceanbase') return 'mysql';
if (normalizedType === 'oceanbase') {
return String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle' ? 'oracle' : 'mysql';
}
if (normalizedType === 'open_gauss' || normalizedType === 'open-gauss') return 'opengauss';
if (normalizedType === 'dameng') return 'dm';
return normalizedType;
@@ -59,6 +61,7 @@ export const resolveSidebarRuntimeDatabase = (
savedDatabase: string,
overrideDatabase?: string,
clearDatabase: boolean = false,
oceanBaseProtocol?: string,
): string => {
if (clearDatabase) return '';
@@ -68,7 +71,7 @@ export const resolveSidebarRuntimeDatabase = (
return normalizedSavedDatabase;
}
const dialect = normalizeSidebarConnectionDialect(type, driver);
const dialect = normalizeSidebarConnectionDialect(type, driver, oceanBaseProtocol);
if (dialect === 'oracle' || dialect === 'dm') {
return normalizedSavedDatabase || normalizedOverrideDatabase;
}

View File

@@ -22,6 +22,7 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
expect(resolveSqlDialect('custom', 'mariadb')).toBe('mariadb');
expect(resolveSqlDialect('custom', 'open_gauss')).toBe('opengauss');
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
expect(isMysqlFamilyDialect('oceanbase')).toBe(true);
expect(isMysqlFamilyDialect('oracle')).toBe(false);

View File

@@ -34,12 +34,23 @@ const optionValues = (values: string[]): ColumnTypeOption[] => values.map((value
const normalizeRawDialect = (value: string): string => String(value || '').trim().toLowerCase();
export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect => {
export const normalizeOceanBaseSqlProtocol = (value: unknown): 'mysql' | 'oracle' => (
String(value || '').trim().toLowerCase() === 'oracle' ? 'oracle' : 'mysql'
);
export const resolveSqlDialect = (
rawType: string,
rawDriver = '',
options?: { oceanBaseProtocol?: unknown },
): SqlDialect => {
const normalized = normalizeRawDialect(rawType);
const driver = normalizeRawDialect(rawDriver);
const source = normalized === 'custom' ? driver : normalized;
if (!source) return 'unknown';
if (source === 'oceanbase' && normalizeOceanBaseSqlProtocol(options?.oceanBaseProtocol) === 'oracle') {
return 'oracle';
}
switch (source) {
case 'postgresql':