mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
♻️ refactor(oceanbase): 完善双协议连接链路
- 抽象 OceanBase 协议解析与运行态参数注入 - 复用 OracleDB 实现 OceanBase Oracle 租户连接能力 - 调整 DDL、schema、SQL 方言和数据源能力判断 - 补充协议优先级、缓存隔离和 RPC 参数测试 - 支持按指定 driver 自动生成 agent revision
This commit is contained in:
@@ -139,6 +139,11 @@ PY
|
||||
fi
|
||||
}
|
||||
|
||||
join_by_comma() {
|
||||
local IFS=,
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
driver_csv=""
|
||||
target_platform=""
|
||||
out_root="dist/driver-agents"
|
||||
@@ -197,6 +202,7 @@ if [[ -n "$driver_csv" ]]; then
|
||||
else
|
||||
drivers=("${DEFAULT_DRIVERS[@]}")
|
||||
fi
|
||||
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
|
||||
|
||||
declare -a platforms=()
|
||||
platform_seen="|"
|
||||
@@ -265,7 +271,7 @@ for platform in "${platforms[@]}"; do
|
||||
|
||||
echo ""
|
||||
echo "🧭 生成 driver-agent revision 指纹:$platform"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
|
||||
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
|
||||
@@ -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 租户选择 MySQL;Oracle 租户选择 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 仍走逐条执行的旧路径
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 发现方式。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -179,6 +179,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
||||
normalized := config
|
||||
normalized.ID = ""
|
||||
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
|
||||
if normalized.Type == "oceanbase" {
|
||||
normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCache(normalized.ConnectionParams)
|
||||
}
|
||||
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
|
||||
normalized.Timeout = 0
|
||||
normalized.SavePassword = false
|
||||
@@ -478,6 +481,13 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" ClickHouse协议=%s", protocol))
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") {
|
||||
protocol := "mysql"
|
||||
if isOceanBaseOracleProtocol(config) {
|
||||
protocol = "oracle"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" OceanBase协议=%s", protocol))
|
||||
}
|
||||
|
||||
if config.UseSSH {
|
||||
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
|
||||
|
||||
@@ -119,3 +119,59 @@ func TestGetCacheKey_KeepClickHouseProtocolIsolation(t *testing.T) {
|
||||
t.Fatalf("expected different cache key for different ClickHouse protocols")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepOceanBaseProtocolIsolation(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "sys@oracle001",
|
||||
Database: "ORCL",
|
||||
ConnectionParams: "protocol=mysql",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "protocol=oracle"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left == right {
|
||||
t.Fatalf("expected different cache key for different OceanBase protocols")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToMySQL(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "root@test",
|
||||
Database: "app",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "protocol=mysql"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected default OceanBase protocol to equal mysql, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_OceanBaseProtocolParamWinsOverAliases(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "ob.local",
|
||||
Port: 2881,
|
||||
User: "root@test",
|
||||
Database: "app",
|
||||
ConnectionParams: "protocol=mysql",
|
||||
}
|
||||
modified := base
|
||||
modified.ConnectionParams = "protocol=mysql&tenantMode=oracle"
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected explicit protocol=mysql to win over alias, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
|
||||
case "oceanbase":
|
||||
if !isOceanBaseOracleProtocol(config) {
|
||||
runConfig.Database = name
|
||||
}
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
|
||||
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
|
||||
runConfig.Database = name
|
||||
case "dameng":
|
||||
@@ -42,7 +46,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
|
||||
return rawDB, rawTable
|
||||
}
|
||||
|
||||
dbType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
dbType := resolveDDLDBType(config)
|
||||
if dbType == "sqlserver" {
|
||||
// SQL Server 的 DB 接口约定:第一个参数是数据库名,schema 由 tableName(如 dbo.users) 自行解析。
|
||||
// 不能把 schema(dbo) 传到第一个参数,否则会拼出 dbo.sys.columns 等无效对象名。
|
||||
|
||||
@@ -50,6 +50,34 @@ func TestNormalizeSchemaAndTable_PostgresStillSplitsQualifiedName(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRunConfig_OceanBaseOracleKeepsServiceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Database: "OBORCL",
|
||||
ConnectionParams: "protocol=oracle",
|
||||
}
|
||||
runConfig := normalizeRunConfig(config, "SYS")
|
||||
|
||||
if runConfig.Database != "OBORCL" {
|
||||
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
ConnectionParams: "protocol=oracle",
|
||||
}, "SYS", "ORDERS")
|
||||
|
||||
if schema != "SYS" || table != "ORDERS" {
|
||||
t.Fatalf("expected OceanBase Oracle schema/table SYS.ORDERS, got %q.%q", schema, table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteTableIdentByType_KingbaseNormalizesQuotedQualifiedTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
|
||||
escapedDbName := strings.ReplaceAll(dbName, "`", "``")
|
||||
query := fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", escapedDbName)
|
||||
dbType := strings.ToLower(strings.TrimSpace(runConfig.Type))
|
||||
dbType := resolveDDLDBType(runConfig)
|
||||
if dbType == "postgres" || dbType == "kingbase" || dbType == "highgo" || dbType == "vastbase" || dbType == "opengauss" {
|
||||
escapedDbName = strings.ReplaceAll(dbName, `"`, `""`)
|
||||
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
|
||||
@@ -142,6 +142,9 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
|
||||
func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||
dbType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
if dbType == "oceanbase" && isOceanBaseOracleProtocol(config) {
|
||||
return "oracle"
|
||||
}
|
||||
if dbType != "custom" {
|
||||
return dbType
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@ func TestResolveDDLDBType_CustomDriverAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDDLDBType_OceanBaseOracleProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
ConnectionParams: "protocol=oracle",
|
||||
}
|
||||
if got := resolveDDLDBType(cfg); got != "oracle" {
|
||||
t.Fatalf("expected OceanBase Oracle protocol to use oracle DDL dialect, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSchemaAndTableByType_PGLikeQuotedQualifiedName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -403,6 +403,9 @@ var driverGoModulePathMap = map[string]string{
|
||||
}
|
||||
|
||||
var driverGoModuleAliasPathMap = map[string][]string{
|
||||
"oceanbase": {
|
||||
"github.com/sijms/go-ora/v2",
|
||||
},
|
||||
"mongodb": {
|
||||
"go.mongodb.org/mongo-driver",
|
||||
},
|
||||
|
||||
75
internal/app/oceanbase_protocol.go
Normal file
75
internal/app/oceanbase_protocol.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func normalizeOceanBaseProtocolForApp(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "oracle", "oracle-mode", "oracle_mode", "oboracle":
|
||||
return "oracle"
|
||||
default:
|
||||
return "mysql"
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOceanBaseProtocolParam(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if queryIndex := strings.Index(text, "?"); queryIndex >= 0 {
|
||||
text = text[queryIndex+1:]
|
||||
}
|
||||
if hashIndex := strings.Index(text, "#"); hashIndex >= 0 {
|
||||
text = text[:hashIndex]
|
||||
}
|
||||
values, err := url.ParseQuery(strings.TrimLeft(strings.TrimSpace(text), "?&"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"protocol", "oceanBaseProtocol", "oceanbaseProtocol", "tenantMode", "compatMode", "mode"} {
|
||||
if value := strings.TrimSpace(values.Get(key)); value != "" {
|
||||
return normalizeOceanBaseProtocolForApp(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeOceanBaseConnectionParamsForCache(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(strings.TrimLeft(text, "?&"))
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
protocol := resolveOceanBaseProtocolParam(raw)
|
||||
for _, key := range []string{"protocol", "oceanBaseProtocol", "oceanbaseProtocol", "tenantMode", "compatMode", "mode"} {
|
||||
values.Del(key)
|
||||
}
|
||||
if strings.EqualFold(protocol, "oracle") {
|
||||
values.Set("protocol", "oracle")
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func isOceanBaseOracleProtocol(config connection.ConnectionConfig) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") {
|
||||
return false
|
||||
}
|
||||
if protocol := resolveOceanBaseProtocolParam(config.ConnectionParams); protocol != "" {
|
||||
return protocol == "oracle"
|
||||
}
|
||||
if protocol := resolveOceanBaseProtocolParam(config.URI); protocol != "" {
|
||||
return protocol == "oracle"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,20 +4,20 @@ package db
|
||||
|
||||
func init() {
|
||||
optionalDriverAgentRevisions = map[string]string{
|
||||
"mariadb": "src-99785848cecbb326",
|
||||
"oceanbase": "src-3e0a7e0e0ab0b619",
|
||||
"diros": "src-3d09d05982abf8cf",
|
||||
"sphinx": "src-85601aaa25456cf1",
|
||||
"sqlserver": "src-703a625726deff98",
|
||||
"sqlite": "src-7d841dda22330f67",
|
||||
"duckdb": "src-6d3b9eefc9802162",
|
||||
"dameng": "src-8a758078ab1fd981",
|
||||
"kingbase": "src-0086ac3cb15dd34d",
|
||||
"highgo": "src-147aec3df7ef7461",
|
||||
"vastbase": "src-cb0a4f1e6633f810",
|
||||
"opengauss": "src-f9924897702b760d",
|
||||
"mongodb": "src-a4eab8b91194dc18",
|
||||
"tdengine": "src-f43c25ea8b55d81f",
|
||||
"clickhouse": "src-c24f610c0f65228f",
|
||||
"mariadb": "src-ac4e31956af63048",
|
||||
"oceanbase": "src-f0c94a098a955e89",
|
||||
"diros": "src-4565a49afb9b942b",
|
||||
"sphinx": "src-4f9ec83df79bc8f7",
|
||||
"sqlserver": "src-172613975f6f18d2",
|
||||
"sqlite": "src-2ff8c7eb368b324b",
|
||||
"duckdb": "src-6d20adc5b77a9ed6",
|
||||
"dameng": "src-659f5656149e216c",
|
||||
"kingbase": "src-82ff6ff9440233cd",
|
||||
"highgo": "src-a3915194d9a50d5d",
|
||||
"vastbase": "src-20413d5fc104e9fc",
|
||||
"opengauss": "src-a4d1946fea5c229c",
|
||||
"mongodb": "src-93d3f3ba9a564a1d",
|
||||
"tdengine": "src-11ff132d18cb7d9a",
|
||||
"clickhouse": "src-8e9642cd16e7e147",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
@@ -15,13 +18,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
oceanbaseDriverName = "oceanbase"
|
||||
defaultOceanBasePort = 2881
|
||||
oceanbaseDriverName = "oceanbase"
|
||||
defaultOceanBasePort = 2881
|
||||
oceanBaseProtocolMySQL = "mysql"
|
||||
oceanBaseProtocolOracle = "oracle"
|
||||
)
|
||||
|
||||
// OceanBaseDB 使用独立 driver 名称接入,底层协议兼容 MySQL。
|
||||
// OceanBaseDB 支持 OceanBase MySQL/Oracle 两种租户协议。
|
||||
type OceanBaseDB struct {
|
||||
MySQLDB
|
||||
oracle *OracleDB
|
||||
protocol string
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -140,8 +147,118 @@ func (o *OceanBaseDB) getDSN(config connection.ConnectionConfig) (string, error)
|
||||
return buildMySQLCompatibleDSN(config, protocol, address, database), nil
|
||||
}
|
||||
|
||||
func normalizeOceanBaseProtocol(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case oceanBaseProtocolOracle, "oracle-mode", "oracle_mode", "oboracle":
|
||||
return oceanBaseProtocolOracle
|
||||
case oceanBaseProtocolMySQL, "mysql-compatible", "mysql_compatible", "mysql-mode", "mysql_mode", "":
|
||||
return oceanBaseProtocolMySQL
|
||||
default:
|
||||
return oceanBaseProtocolMySQL
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOceanBaseProtocolFromValues(values url.Values) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"protocol", "oceanBaseProtocol", "oceanbaseProtocol", "tenantMode", "compatMode", "mode"} {
|
||||
if value := strings.TrimSpace(values.Get(key)); value != "" {
|
||||
return normalizeOceanBaseProtocol(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveOceanBaseProtocol(config connection.ConnectionConfig) string {
|
||||
if protocol := resolveOceanBaseProtocolFromValues(connectionParamsFromText(config.ConnectionParams)); protocol != "" {
|
||||
return protocol
|
||||
}
|
||||
if protocol := resolveOceanBaseProtocolFromValues(connectionParamsFromURI(config.URI, "oceanbase", "mysql")); protocol != "" {
|
||||
return protocol
|
||||
}
|
||||
return oceanBaseProtocolMySQL
|
||||
}
|
||||
|
||||
func stripOceanBaseProtocolParams(raw string) string {
|
||||
values := connectionParamsFromText(raw)
|
||||
if len(values) == 0 {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
for _, key := range []string{"protocol", "oceanBaseProtocol", "oceanbaseProtocol", "tenantMode", "compatMode", "mode"} {
|
||||
values.Del(key)
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func stripOceanBaseProtocolURI(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
parsed, ok := parseConnectionURI(text, "oceanbase", "mysql")
|
||||
if !ok {
|
||||
return text
|
||||
}
|
||||
values := parsed.Query()
|
||||
if len(values) == 0 {
|
||||
return text
|
||||
}
|
||||
for _, key := range []string{"protocol", "oceanBaseProtocol", "oceanbaseProtocol", "tenantMode", "compatMode", "mode"} {
|
||||
values.Del(key)
|
||||
}
|
||||
parsed.RawQuery = values.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func withoutOceanBaseProtocolParams(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
next := config
|
||||
next.ConnectionParams = stripOceanBaseProtocolParams(config.ConnectionParams)
|
||||
next.URI = stripOceanBaseProtocolURI(config.URI)
|
||||
return next
|
||||
}
|
||||
|
||||
func isOceanBaseOracleTenantMySQLDriverError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "oracle tenant") && strings.Contains(text, "not supported")
|
||||
}
|
||||
|
||||
func formatOceanBaseMySQLAttemptError(address string, err error) string {
|
||||
if isOceanBaseOracleTenantMySQLDriverError(err) {
|
||||
return fmt.Sprintf("%s 验证失败: 当前选择的是 OceanBase MySQL 协议,但服务端返回 Oracle 租户不支持 MySQL 客户端驱动;请在连接配置中将 OceanBase 协议切换为 Oracle,并填写服务名 (Service Name)", address)
|
||||
}
|
||||
return fmt.Sprintf("%s 验证失败: %v", address, err)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) connectOracle(config connection.ConnectionConfig) error {
|
||||
runConfig := withoutOceanBaseProtocolParams(applyOceanBaseURI(config))
|
||||
runConfig.Type = "oracle"
|
||||
if strings.TrimSpace(runConfig.Database) == "" {
|
||||
return fmt.Errorf("OceanBase Oracle 协议需要填写服务名(Service Name),请在连接配置中填写租户监听的服务名")
|
||||
}
|
||||
oracleDB := &OracleDB{}
|
||||
if err := oracleDB.Connect(runConfig); err != nil {
|
||||
return fmt.Errorf("OceanBase Oracle 协议连接失败:%w", err)
|
||||
}
|
||||
o.oracle = oracleDB
|
||||
o.protocol = oceanBaseProtocolOracle
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
runConfig := applyOceanBaseURI(config)
|
||||
o.oracle = nil
|
||||
o.protocol = oceanBaseProtocolMySQL
|
||||
appliedConfig := applyOceanBaseURI(config)
|
||||
protocol := resolveOceanBaseProtocol(appliedConfig)
|
||||
runConfig := withoutOceanBaseProtocolParams(appliedConfig)
|
||||
if protocol == oceanBaseProtocolOracle {
|
||||
logger.Infof("OceanBase 使用 Oracle 协议连接:地址=%s:%d 用户=%s", runConfig.Host, runConfig.Port, runConfig.User)
|
||||
return o.connectOracle(runConfig)
|
||||
}
|
||||
|
||||
addresses := collectOceanBaseAddresses(runConfig)
|
||||
if len(addresses) == 0 {
|
||||
return fmt.Errorf("连接建立后验证失败:未找到可用的 OceanBase 地址")
|
||||
@@ -175,12 +292,13 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
cancel()
|
||||
if pingErr != nil {
|
||||
_ = db.Close()
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 验证失败: %v", address, pingErr))
|
||||
errorDetails = append(errorDetails, formatOceanBaseMySQLAttemptError(address, pingErr))
|
||||
continue
|
||||
}
|
||||
|
||||
o.conn = db
|
||||
o.pingTimeout = timeout
|
||||
o.protocol = oceanBaseProtocolMySQL
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -189,3 +307,117 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
}
|
||||
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ";"))
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) activeDatabase() Database {
|
||||
if o.oracle != nil {
|
||||
return o.oracle
|
||||
}
|
||||
return &o.MySQLDB
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) Close() error {
|
||||
if o.oracle != nil {
|
||||
err := o.oracle.Close()
|
||||
o.oracle = nil
|
||||
return err
|
||||
}
|
||||
return o.MySQLDB.Close()
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) Ping() error {
|
||||
return o.activeDatabase().Ping()
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if q, ok := o.activeDatabase().(interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}); ok {
|
||||
return q.QueryContext(ctx, query)
|
||||
}
|
||||
return o.activeDatabase().Query(query)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
return o.activeDatabase().Query(query)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if e, ok := o.activeDatabase().(interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}); ok {
|
||||
return e.ExecContext(ctx, query)
|
||||
}
|
||||
return o.activeDatabase().Exec(query)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) Exec(query string) (int64, error) {
|
||||
return o.activeDatabase().Exec(query)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
|
||||
if q, ok := o.activeDatabase().(MultiResultQuerier); ok {
|
||||
return q.QueryMulti(query)
|
||||
}
|
||||
data, columns, err := o.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []connection.ResultSetData{{Rows: data, Columns: columns}}, nil
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
|
||||
if q, ok := o.activeDatabase().(MultiResultQuerierContext); ok {
|
||||
return q.QueryMultiContext(ctx, query)
|
||||
}
|
||||
data, columns, err := o.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []connection.ResultSetData{{Rows: data, Columns: columns}}, nil
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
|
||||
if e, ok := o.activeDatabase().(BatchWriteExecer); ok {
|
||||
return e.ExecBatchContext(ctx, query)
|
||||
}
|
||||
return o.ExecContext(ctx, query)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetDatabases() ([]string, error) {
|
||||
return o.activeDatabase().GetDatabases()
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetTables(dbName string) ([]string, error) {
|
||||
return o.activeDatabase().GetTables(dbName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return o.activeDatabase().GetCreateStatement(dbName, tableName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return o.activeDatabase().GetColumns(dbName, tableName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return o.activeDatabase().GetAllColumns(dbName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return o.activeDatabase().GetIndexes(dbName, tableName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return o.activeDatabase().GetForeignKeys(dbName, tableName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return o.activeDatabase().GetTriggers(dbName, tableName)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if applier, ok := o.activeDatabase().(BatchApplier); ok {
|
||||
return applier.ApplyChanges(tableName, changes)
|
||||
}
|
||||
return fmt.Errorf("当前 OceanBase %s 协议不支持 ApplyChanges", o.protocol)
|
||||
}
|
||||
|
||||
131
internal/db/oceanbase_impl_test.go
Normal file
131
internal/db/oceanbase_impl_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
//go:build gonavi_full_drivers || gonavi_oceanbase_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestResolveOceanBaseProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config connection.ConnectionConfig
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "default mysql",
|
||||
config: connection.ConnectionConfig{Type: "oceanbase"},
|
||||
want: oceanBaseProtocolMySQL,
|
||||
},
|
||||
{
|
||||
name: "explicit oracle params",
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
ConnectionParams: "protocol=oracle",
|
||||
},
|
||||
want: oceanBaseProtocolOracle,
|
||||
},
|
||||
{
|
||||
name: "uri protocol oracle",
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
URI: "oceanbase://sys%40oracle001:pass@127.0.0.1:2881/ORCL?protocol=oracle",
|
||||
},
|
||||
want: oceanBaseProtocolOracle,
|
||||
},
|
||||
{
|
||||
name: "connection params tenant mode oracle",
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
ConnectionParams: "tenantMode=oracle&PREFETCH_ROWS=5000",
|
||||
},
|
||||
want: oceanBaseProtocolOracle,
|
||||
},
|
||||
{
|
||||
name: "connection params wins over uri",
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
URI: "oceanbase://root:pass@127.0.0.1:2881/app?protocol=oracle",
|
||||
ConnectionParams: "protocol=mysql",
|
||||
},
|
||||
want: oceanBaseProtocolMySQL,
|
||||
},
|
||||
{
|
||||
name: "protocol key wins over compatibility aliases",
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
ConnectionParams: "protocol=mysql&tenantMode=oracle",
|
||||
},
|
||||
want: oceanBaseProtocolMySQL,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := resolveOceanBaseProtocol(tt.config); got != tt.want {
|
||||
t.Fatalf("resolveOceanBaseProtocol() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutOceanBaseProtocolParamsStripsDriverMeta(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config := withoutOceanBaseProtocolParams(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
URI: "oceanbase://root:pass@127.0.0.1:2881/app?protocol=mysql&timeout=10",
|
||||
ConnectionParams: "tenantMode=oracle&PREFETCH_ROWS=5000",
|
||||
})
|
||||
|
||||
if strings.Contains(config.URI, "protocol=") {
|
||||
t.Fatalf("expected URI protocol param stripped, got %q", config.URI)
|
||||
}
|
||||
if strings.Contains(config.ConnectionParams, "tenantMode=") {
|
||||
t.Fatalf("expected connection param tenantMode stripped, got %q", config.ConnectionParams)
|
||||
}
|
||||
if !strings.Contains(config.URI, "timeout=10") {
|
||||
t.Fatalf("expected URI business params kept, got %q", config.URI)
|
||||
}
|
||||
if !strings.Contains(config.ConnectionParams, "PREFETCH_ROWS=5000") {
|
||||
t.Fatalf("expected Oracle params kept, got %q", config.ConnectionParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOceanBaseOracleRequiresServiceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := (&OceanBaseDB{}).Connect(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "127.0.0.1",
|
||||
Port: 2881,
|
||||
User: "sys@oracle001",
|
||||
ConnectionParams: "protocol=oracle",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing service name error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "服务名") {
|
||||
t.Fatalf("expected service name hint, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOceanBaseMySQLAttemptErrorHintsOracleProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := formatOceanBaseMySQLAttemptError(
|
||||
"127.0.0.1:2881",
|
||||
errors.New("Error 1235 (0A000): Oracle tenant for current client driver is not supported"),
|
||||
)
|
||||
if !strings.Contains(got, "切换为 Oracle") {
|
||||
t.Fatalf("expected Oracle protocol hint, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ usage() {
|
||||
|
||||
选项:
|
||||
--platform <GOOS/GOARCH> 按目标平台解析 Go build tags,默认使用当前 Go 环境
|
||||
--drivers <列表> 指定驱动列表(逗号分隔),默认生成所有 optional driver
|
||||
--drivers <列表> 只更新指定驱动(逗号分隔),并保留其他已生成 revision
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
@@ -174,6 +174,33 @@ else
|
||||
drivers=("${DEFAULT_DRIVERS[@]}")
|
||||
fi
|
||||
|
||||
selected_driver_set="|"
|
||||
for driver in "${drivers[@]}"; do
|
||||
selected_driver_set="${selected_driver_set}${driver}|"
|
||||
done
|
||||
|
||||
existing_revision_for() {
|
||||
local target="$1"
|
||||
local line
|
||||
[[ -n "$driver_csv" && -f "$OUTPUT_FILE" ]] || return 1
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ \"([^\"]+)\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
||||
if [[ "${BASH_REMATCH[1]}" == "$target" ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done <"$OUTPUT_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
declare -a output_drivers=()
|
||||
if [[ -n "$driver_csv" ]]; then
|
||||
output_drivers=("${DEFAULT_DRIVERS[@]}")
|
||||
else
|
||||
output_drivers=("${drivers[@]}")
|
||||
fi
|
||||
|
||||
fingerprint_driver() {
|
||||
local driver="$1"
|
||||
local build_driver tag cgo_enabled tmp file identity file_hash revision
|
||||
@@ -236,8 +263,12 @@ package db
|
||||
func init() {
|
||||
optionalDriverAgentRevisions = map[string]string{
|
||||
EOF
|
||||
for driver in "${drivers[@]}"; do
|
||||
revision="$(fingerprint_driver "$driver")"
|
||||
for driver in "${output_drivers[@]}"; do
|
||||
if [[ -n "$driver_csv" && "$selected_driver_set" != *"|$driver|"* ]] && revision="$(existing_revision_for "$driver")"; then
|
||||
:
|
||||
else
|
||||
revision="$(fingerprint_driver "$driver")"
|
||||
fi
|
||||
printf '\t\t"%s": "%s",\n' "$driver" "$revision"
|
||||
done
|
||||
cat <<'EOF'
|
||||
|
||||
Reference in New Issue
Block a user