mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(oracle): support oci thick connections via windows agent
This commit is contained in:
@@ -54,6 +54,7 @@ const nativeDriverDirectories = {
|
||||
hive: "hive-go",
|
||||
argo: "argo-go",
|
||||
oracle: "oracle-go",
|
||||
"oracle-oci": "oracle-go",
|
||||
kingbase: "kingbase-go",
|
||||
iotdb: "iotdb",
|
||||
neo4j: "neo4j-go",
|
||||
@@ -69,7 +70,7 @@ const nativeDriverDirectories = {
|
||||
const crateNativeDriverDirectories = {
|
||||
"sqlite-worker": "crates/dbx-sqlite-worker",
|
||||
};
|
||||
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "argo", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]);
|
||||
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "argo", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]);
|
||||
const nativeDriverSharedPaths = {
|
||||
hive: [
|
||||
"agents/go-common/go-gssapi",
|
||||
|
||||
@@ -48,6 +48,38 @@ test("bumps the SQLite SSH worker from its crate path", () => {
|
||||
assert.deepEqual(result.nativeModules, ["sqlite-worker"]);
|
||||
});
|
||||
|
||||
test("bumps both Oracle variants when their shared module changes", () => {
|
||||
// `oracle` and `oracle-oci` are built from the same `oracle-go` module (with
|
||||
// and without the `oci` build tag), so a change there must rebuild and
|
||||
// re-release both artifacts — otherwise the `oci` variant silently keeps a
|
||||
// stale version and `reuse-previous-assets` re-publishes the old binary.
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { oracle: "0.1.66", "oracle-oci": "0.1.0" },
|
||||
changedFiles: ["agents/drivers/oracle-go/oci.go"],
|
||||
moduleExists: (path) => path === "agents/drivers/oracle-go",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.oracle, "0.1.67");
|
||||
assert.equal(result.versions["oracle-oci"], "0.1.1");
|
||||
assert.deepEqual(result.changedModules.sort(), ["oracle", "oracle-oci"]);
|
||||
assert.deepEqual(result.nativeModules.sort(), ["oracle", "oracle-oci"]);
|
||||
});
|
||||
|
||||
test("keeps a manually versioned Oracle OCI release without touching the thin variant", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { oracle: "0.1.66", "oracle-oci": "0.1.0" },
|
||||
prevVersions: { oracle: "0.1.66" },
|
||||
changedFiles: ["agents/versions.json"],
|
||||
moduleExists: (path) => path === "agents/drivers/oracle-go",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions["oracle-oci"], "0.1.0");
|
||||
assert.equal(result.versions.oracle, "0.1.66");
|
||||
assert.deepEqual(result.changedModules, ["oracle-oci"]);
|
||||
});
|
||||
|
||||
test("classifies TDengine Rust changes as native-only", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { tdengine: "0.1.39" },
|
||||
|
||||
@@ -15,7 +15,14 @@ import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const REGISTRY_ASSET = "agent-registry.json";
|
||||
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2"]);
|
||||
const NATIVE_MODULES = new Set(["duckdb", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2"]);
|
||||
// Native modules that intentionally publish a subset of the six platforms. The
|
||||
// Oracle OCI ("thick") agent links the Oracle Client through CGO and is built
|
||||
// for Windows x64 only, so a reused release must cover exactly that platform —
|
||||
// requiring all six would reject a perfectly complete release.
|
||||
const NATIVE_MODULE_PLATFORMS = {
|
||||
"oracle-oci": ["windows-x64"],
|
||||
};
|
||||
const PLATFORMS = [
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
@@ -25,6 +32,10 @@ const PLATFORMS = [
|
||||
"windows-x64",
|
||||
];
|
||||
|
||||
function expectedNativePlatforms(moduleName) {
|
||||
return [...(NATIVE_MODULE_PLATFORMS[moduleName] ?? PLATFORMS)].sort();
|
||||
}
|
||||
|
||||
function artifactFilename(url) {
|
||||
return basename(url.split(/[?#]/, 1)[0]);
|
||||
}
|
||||
@@ -78,8 +89,9 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
|
||||
|
||||
const nativePlatforms = Object.keys(driver.native ?? {}).sort();
|
||||
if (NATIVE_MODULES.has(moduleName)) {
|
||||
const missingPlatforms = PLATFORMS.filter((platform) => !nativePlatforms.includes(platform));
|
||||
const extraPlatforms = nativePlatforms.filter((platform) => !PLATFORMS.includes(platform));
|
||||
const expectedPlatforms = expectedNativePlatforms(moduleName);
|
||||
const missingPlatforms = expectedPlatforms.filter((platform) => !nativePlatforms.includes(platform));
|
||||
const extraPlatforms = nativePlatforms.filter((platform) => !expectedPlatforms.includes(platform));
|
||||
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
|
||||
throw new Error(
|
||||
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
|
||||
@@ -223,7 +235,7 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
|
||||
extracted += 1;
|
||||
}
|
||||
|
||||
if (NATIVE_MODULES.has(moduleName) && nativePlatforms.length !== PLATFORMS.length) {
|
||||
if (NATIVE_MODULES.has(moduleName) && nativePlatforms.length !== expectedNativePlatforms(moduleName).length) {
|
||||
throw new Error(`Reusable native package set is incomplete for ${moduleName}.`);
|
||||
}
|
||||
if (!filenames.has(javaName) && nativePlatforms.length === 0) {
|
||||
|
||||
@@ -132,6 +132,39 @@ test("requires all RocketMQ native platforms when reusing a release", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("reuses the Windows-only Oracle OCI native package", () => {
|
||||
const native = { "windows-x64": artifact("dbx-agent-oracle-oci-0.1.0-windows-x64.tar.zst", "a") };
|
||||
const registry = { drivers: { "oracle-oci": { version: "0.1.0", native } }, jres: {} };
|
||||
|
||||
const plan = collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { "oracle-oci": "0.1.0" },
|
||||
modules: ["oracle-oci"],
|
||||
reuseJre: false,
|
||||
});
|
||||
|
||||
// Windows-only is the complete platform set for this module, so requiring all
|
||||
// six platforms would reject a correct release.
|
||||
assert.deepEqual(plan.driverAssets.map((asset) => asset.platform), ["windows-x64"]);
|
||||
});
|
||||
|
||||
test("rejects an Oracle OCI release that lost its Windows native package", () => {
|
||||
const native = { "linux-x64": artifact("dbx-agent-oracle-oci-0.1.0-linux-x64.tar.zst", "a") };
|
||||
const registry = { drivers: { "oracle-oci": { version: "0.1.0", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { "oracle-oci": "0.1.0" },
|
||||
modules: ["oracle-oci"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64, extra=linux-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
|
||||
|
||||
@@ -214,6 +214,62 @@ jobs:
|
||||
name: oracle-native
|
||||
path: "release-native/dbx-agent-oracle-*"
|
||||
|
||||
# Oracle OCI(thick)变体:同一个 oracle-go 模块加上 `oci` 构建标记,用 CGO 链接
|
||||
# Oracle 客户端,因此需要 gcc(MinGW-w64)与 Instant Client 的 SDK(头文件 + 导入库)。
|
||||
# Instant Client 是分平台的,本任务只产出 Windows x64(当前 OCI 模式仅支持 Windows)。
|
||||
build-oracle-oci-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'oracle-oci') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
# godror 需要 Go >= 1.21;thin 的 Windows 7 兼容线仍由 build-oracle-native 用 Go 1.20 构建。
|
||||
go-version: "1.25.x"
|
||||
cache-dependency-path: agents/drivers/oracle-go/go.sum
|
||||
- name: Prepare MinGW-w64 and the Oracle Instant Client
|
||||
shell: bash
|
||||
run: |
|
||||
cc="$(command -v gcc || true)"
|
||||
if [ -z "$cc" ]; then
|
||||
choco install mingw -y --no-progress
|
||||
cc="$(ls /c/ProgramData/chocolatey/lib/mingw/tools/install/mingw64/bin/gcc.exe | head -1)"
|
||||
fi
|
||||
mkdir -p instantclient
|
||||
curl -fsSL -o ic-basic.zip "https://download.oracle.com/otn_software/nt/instantclient/2326300/instantclient-basic-windows.x64-23.26.3.0.0.zip"
|
||||
curl -fsSL -o ic-sdk.zip "https://download.oracle.com/otn_software/nt/instantclient/2326300/instantclient-sdk-windows.x64-23.26.3.0.0.zip"
|
||||
tar -xf ic-basic.zip -C instantclient
|
||||
tar -xf ic-sdk.zip -C instantclient
|
||||
ic_dir="$(ls -d "${GITHUB_WORKSPACE}/instantclient"/instantclient_* | head -1)"
|
||||
echo "MinGW gcc: $cc / Instant Client: $ic_dir"
|
||||
{
|
||||
echo "CGO_ENABLED=1"
|
||||
echo "GOOS=windows"
|
||||
echo "GOARCH=amd64"
|
||||
echo "CC=${cc}"
|
||||
echo "CGO_CFLAGS=-I${ic_dir}/sdk/include"
|
||||
echo "CGO_LDFLAGS=-L${ic_dir}/sdk/lib/msvc"
|
||||
} >> "$GITHUB_ENV"
|
||||
- name: Test Oracle agent with the OCI build tag
|
||||
shell: bash
|
||||
working-directory: agents/drivers/oracle-go
|
||||
run: go test -mod=mod -tags oci ./...
|
||||
- name: Build Oracle OCI agent (Windows x64)
|
||||
shell: bash
|
||||
working-directory: agents/drivers/oracle-go
|
||||
run: |
|
||||
mkdir -p ../../../release-native
|
||||
output="../../../release-native/dbx-agent-oracle-oci-windows-x64.exe"
|
||||
# go.mod 的 go 指令保持 1.20(thin 的 Windows 7 兼容线);OCI 变体要求更高,
|
||||
# 因此允许就地更新 go.mod —— 只影响本任务的检出目录,不会被提交。
|
||||
go build -mod=mod -tags oci -trimpath -ldflags="-s -w" -o "$output" .
|
||||
ls -lh ../../../release-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oracle-oci-native
|
||||
path: "release-native/dbx-agent-oracle-oci-*"
|
||||
|
||||
build-xugu-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'xugu') }}
|
||||
@@ -1295,7 +1351,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-rocketmq-native, build-etcd-native, build-etcd2-native, build-zookeeper-native, build-cassandra-native, build-hive-native, build-argo-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-nebula-native, build-iotdb-native, build-duckdb-native, build-sqlite-worker-native, build-tdengine-native, build-jre, reuse-previous-assets]
|
||||
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-oracle-oci-native, build-xugu-native, build-rabbitmq-native, build-rocketmq-native, build-etcd-native, build-etcd2-native, build-zookeeper-native, build-cassandra-native, build-hive-native, build-argo-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-nebula-native, build-iotdb-native, build-duckdb-native, build-sqlite-worker-native, build-tdengine-native, build-jre, reuse-previous-assets]
|
||||
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1434,6 +1490,7 @@ jobs:
|
||||
tdengine) echo "TDengine" ;;
|
||||
etcd) echo "etcd" ;;
|
||||
etcd2) echo "etcd 2.x (v2 API)" ;;
|
||||
oracle-oci) echo "Oracle (OCI)" ;;
|
||||
*) echo "$name" ;;
|
||||
esac
|
||||
}
|
||||
@@ -1497,7 +1554,7 @@ jobs:
|
||||
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
|
||||
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
|
||||
done
|
||||
for name in oracle xugu kingbase vastbase neo4j nebula iotdb duckdb sqlite-worker rabbitmq rocketmq zookeeper cassandra hive argo tdengine etcd etcd2; do
|
||||
for name in oracle oracle-oci xugu kingbase vastbase neo4j nebula iotdb duckdb sqlite-worker rabbitmq rocketmq zookeeper cassandra hive argo tdengine etcd etcd2; do
|
||||
version=$(get_module_version "$name")
|
||||
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
|
||||
native_json=$(generate_native_platforms "$name" "$version")
|
||||
@@ -1592,6 +1649,7 @@ jobs:
|
||||
tdengine) echo "TDengine" ;;
|
||||
etcd) echo "etcd" ;;
|
||||
etcd2) echo "etcd 2.x (v2 API)" ;;
|
||||
oracle-oci) echo "Oracle (OCI)" ;;
|
||||
*) echo "$name" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
| gbase8a | GBase 8a | External GBase 8a JDBC |
|
||||
| gbase8s | GBase 8s | External GBase 8s JDBC |
|
||||
| oracle | Oracle 10g+ | go-ora native agent |
|
||||
| oracle-oci | Oracle OCI (thick driver) | godror native agent (Windows x64, CGO; shares `drivers/oracle-go`, built with `-tags oci`) |
|
||||
| h2 | H2 | H2 JDBC |
|
||||
| snowflake | Snowflake | Snowflake JDBC |
|
||||
| trino | Trino (Presto) | Trino JDBC |
|
||||
|
||||
@@ -34,3 +34,39 @@ To restore the Java agent:
|
||||
```bash
|
||||
rm ~/.dbx/agents/drivers/oracle/agent
|
||||
```
|
||||
|
||||
## OCI (thick driver) variant
|
||||
|
||||
The `oracle-oci` driver shipped for Windows x64 is this same agent compiled with
|
||||
the `oci` build tag, which swaps the pure-Go `go-ora` driver for
|
||||
`github.com/godror/godror` (Oracle Call Interface through CGO):
|
||||
|
||||
- Connections that select the OCI driver profile (`driver_profile: "oci"`) use
|
||||
the OCI driver; every other Oracle connection keeps the thin `go-ora` path,
|
||||
and a thin binary asked for OCI fails with an explicit "install the
|
||||
oracle-oci driver" error instead of a driver-not-found surprise.
|
||||
- The agent builds a `user/password@connectString [AS SYSDBA]` DSN. Service
|
||||
name / SID / descriptor / TNS alias all come from the connection string DBX
|
||||
sends; `buildOCIDSN` in `oci_dsn.go` covers those rules and is unit tested
|
||||
without the build tag, so the regular CI test run covers them.
|
||||
- Process-scoped client settings come from DBX's launch environment: the
|
||||
Instant Client directory is prepended to `PATH`, `TNS_ADMIN` points at
|
||||
`tnsnames.ora`, and `NLS_LANG` sets the client character set.
|
||||
|
||||
### Building the OCI variant (Windows x64)
|
||||
|
||||
Building requires the Oracle Instant Client **SDK** (headers + import library)
|
||||
and a C compiler for CGO (MinGW-w64 GCC):
|
||||
|
||||
```powershell
|
||||
$env:CGO_ENABLED=1
|
||||
$env:GOOS="windows"; $env:GOARCH="amd64"
|
||||
$env:CC="C:/mingw64/bin/gcc.exe"
|
||||
$env:CGO_CFLAGS="-IC:/instantclient/sdk/include"
|
||||
$env:CGO_LDFLAGS="-LC:/instantclient/sdk/lib/msvc"
|
||||
# go.mod keeps `go 1.20` for the Windows 7-compatible thin build; the OCI
|
||||
# variant needs Go >= 1.21 (godror), so -mod=mod updates go.mod in place.
|
||||
go build -mod=mod -tags oci -trimpath -ldflags="-s -w" -o oracle-oci.exe .
|
||||
```
|
||||
|
||||
At runtime only `oci.dll` is needed, located through `PATH`.
|
||||
|
||||
@@ -4,4 +4,15 @@ go 1.20
|
||||
|
||||
require github.com/sijms/go-ora/v2 v2.9.0
|
||||
|
||||
require golang.org/x/text v0.22.0
|
||||
require (
|
||||
github.com/godror/godror v0.51.5
|
||||
golang.org/x/text v0.22.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/VictoriaMetrics/easyproto v0.1.4 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/godror/knownpb v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
github.com/VictoriaMetrics/easyproto v0.1.4 h1:r8cNvo8o6sR4QShBXQd1bKw/VVLSQma/V2KhTBPf+Sc=
|
||||
github.com/VictoriaMetrics/easyproto v0.1.4/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/godror/godror v0.40.0 h1:ZVsBx4XAkfVy2uHWpTTGMWbaZP1olTx78MDkQOJEjqM=
|
||||
github.com/godror/godror v0.40.0/go.mod h1:WoiPqkjCGtKxZ5gJtB64kmOUy6O2h7dR4dxdsnxjgCA=
|
||||
github.com/godror/godror v0.51.5 h1:NFvDtLILwg5mTU31DtL7Ae2AQvkDNL7nip+pSdMS4ow=
|
||||
github.com/godror/godror v0.51.5/go.mod h1:ZxKkyFw54Ou5CGeXhP4EjK0s9PSB+2W87GyL765XbO8=
|
||||
github.com/godror/knownpb v0.3.0 h1:+caUdy8hTtl7X05aPl3tdL540TvCcaQA6woZQroLZMw=
|
||||
github.com/godror/knownpb v0.3.0/go.mod h1:PpTyfJwiOEAzQl7NtVCM8kdPCnp3uhxsZYIzZ5PV4zU=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc=
|
||||
github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68=
|
||||
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
|
||||
github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
|
||||
@@ -285,6 +285,13 @@ type connectParams struct {
|
||||
SysDBA bool `json:"sysdba"`
|
||||
URLParams string `json:"url_params"`
|
||||
ConnectionString string `json:"connection_string"`
|
||||
// DriverProfile 由 DBX 下传;"oci" 走 OCI(thick)驱动,其余走内置的 thin 驱动。
|
||||
DriverProfile string `json:"driver_profile"`
|
||||
}
|
||||
|
||||
// usesOCIProfile 判断本次连接是否选择了 OCI(thick)驱动。
|
||||
func usesOCIProfile(params connectParams) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(params.DriverProfile), "oci")
|
||||
}
|
||||
|
||||
type completionAssistantRequest struct {
|
||||
@@ -1256,6 +1263,11 @@ func openDB(params connectParams) (*sql.DB, error) {
|
||||
}
|
||||
|
||||
func openDBWithStringConverter(params connectParams, stringConverter converters.IStringConverter) (*sql.DB, error) {
|
||||
if usesOCIProfile(params) {
|
||||
// OCI 由 Oracle 客户端负责字符集转换(NLS_LANG 在进程环境里生效),
|
||||
// go-ora 的 string converter 机制不适用于它。
|
||||
return openOCIDB(params)
|
||||
}
|
||||
dsn, err := buildDSNForConnect(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1278,6 +1290,10 @@ func openAndPingDB(params connectParams, timeout time.Duration) (*sql.DB, error)
|
||||
}
|
||||
if err := pingDB(db, timeout); err != nil {
|
||||
db.Close()
|
||||
if usesOCIProfile(params) {
|
||||
// OCI 的字符集由客户端决定,重试 go-ora 的转换器没有意义。
|
||||
return nil, err
|
||||
}
|
||||
stringConverter, ok := oracleStringConverterForUnsupportedCharsetError(err)
|
||||
if !ok {
|
||||
return nil, err
|
||||
@@ -1472,25 +1488,56 @@ var (
|
||||
func parseOracleJDBCURL(value string) jdbcURLInfo {
|
||||
value = strings.TrimSpace(value)
|
||||
lower := strings.ToLower(value)
|
||||
if !strings.HasPrefix(lower, "jdbc:oracle:thin:@") {
|
||||
prefix := ""
|
||||
for _, candidate := range []string{"jdbc:oracle:thin:@", "jdbc:oracle:oci8:@", "jdbc:oracle:oci:@"} {
|
||||
if strings.HasPrefix(lower, candidate) {
|
||||
prefix = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if prefix == "" {
|
||||
return jdbcURLInfo{}
|
||||
}
|
||||
descriptor := strings.TrimSpace(value[len("jdbc:oracle:thin:@"):])
|
||||
descriptor := strings.TrimSpace(value[len(prefix):])
|
||||
if strings.HasPrefix(descriptor, "(") {
|
||||
return jdbcURLInfo{Kind: "descriptor", Descriptor: descriptor}
|
||||
}
|
||||
if match := oracleJDBCServiceRegexp.FindStringSubmatch(value); len(match) == 4 {
|
||||
if alias := oracleTnsAliasName(descriptor); alias != "" {
|
||||
return jdbcURLInfo{Kind: "tns", Database: alias}
|
||||
}
|
||||
// 下面的正则按 thin 前缀书写,统一归一后再匹配,因此 oci8 与 thin 共用一套解析。
|
||||
normalized := "jdbc:oracle:thin:@" + descriptor
|
||||
if match := oracleJDBCServiceRegexp.FindStringSubmatch(normalized); len(match) == 4 {
|
||||
return jdbcURLInfo{Kind: "service", Host: match[1], Port: parsePort(match[2]), Database: match[3]}
|
||||
}
|
||||
if match := oracleJDBCSIDRegexp.FindStringSubmatch(value); len(match) == 4 {
|
||||
if match := oracleJDBCSIDRegexp.FindStringSubmatch(normalized); len(match) == 4 {
|
||||
return jdbcURLInfo{Kind: "sid", Host: match[1], Port: parsePort(match[2]), Database: match[3]}
|
||||
}
|
||||
if match := oracleJDBCLegacyRegexp.FindStringSubmatch(value); len(match) == 4 {
|
||||
if match := oracleJDBCLegacyRegexp.FindStringSubmatch(normalized); len(match) == 4 {
|
||||
return jdbcURLInfo{Kind: "service", Host: match[1], Port: parsePort(match[2]), Database: match[3]}
|
||||
}
|
||||
return jdbcURLInfo{}
|
||||
}
|
||||
|
||||
// oracleTnsAliasName 返回 `@` 之后、查询串之前的 TNS 别名;不是别名时返回空串。
|
||||
//
|
||||
// 判定与前端 parseOracleTnsConnectionString 保持一致:别名不含描述符括号、
|
||||
// 网络前缀(//)以及路径/端口分隔符(: / \)。
|
||||
func oracleTnsAliasName(value string) string {
|
||||
alias := value
|
||||
if index := strings.IndexByte(alias, '?'); index >= 0 {
|
||||
alias = alias[:index]
|
||||
}
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" || strings.HasPrefix(alias, "(") || strings.HasPrefix(alias, "//") {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(alias, ":/\\") {
|
||||
return ""
|
||||
}
|
||||
return alias
|
||||
}
|
||||
|
||||
func parsePort(value string) int {
|
||||
port, _ := strconv.Atoi(value)
|
||||
return port
|
||||
|
||||
@@ -4345,3 +4345,78 @@ func TestRewriteOracleOpaqueObjectExplicitColumnKeepsLinkedAlias(t *testing.T) {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
// OCI 模式的连接串解析:oci8 前缀、TNS 别名与 thin 形式共用同一套规则。
|
||||
func TestParseOracleJDBCURLAcceptsOci8FormsAndTnsAliases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want jdbcURLInfo
|
||||
}{
|
||||
{
|
||||
name: "service",
|
||||
url: "jdbc:oracle:oci8:@//db.example.com:1521/ORCLPDB1",
|
||||
want: jdbcURLInfo{Kind: "service", Host: "db.example.com", Port: 1521, Database: "ORCLPDB1"},
|
||||
},
|
||||
{
|
||||
name: "sid",
|
||||
url: "jdbc:oracle:oci8:@db.example.com:1521:ORCL",
|
||||
want: jdbcURLInfo{Kind: "sid", Host: "db.example.com", Port: 1521, Database: "ORCL"},
|
||||
},
|
||||
{
|
||||
name: "descriptor",
|
||||
url: "jdbc:oracle:oci8:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=x)))",
|
||||
want: jdbcURLInfo{Kind: "descriptor", Descriptor: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=x)))"},
|
||||
},
|
||||
{
|
||||
name: "tns alias",
|
||||
url: "jdbc:oracle:oci8:@ORCLPDB1",
|
||||
want: jdbcURLInfo{Kind: "tns", Database: "ORCLPDB1"},
|
||||
},
|
||||
{
|
||||
name: "tns alias with admin query",
|
||||
url: "jdbc:oracle:oci8:@ORCLPDB1?TNS_ADMIN=C:/wallets",
|
||||
want: jdbcURLInfo{Kind: "tns", Database: "ORCLPDB1"},
|
||||
},
|
||||
{
|
||||
name: "thin form still parses",
|
||||
url: "jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1",
|
||||
want: jdbcURLInfo{Kind: "service", Host: "db.example.com", Port: 1521, Database: "ORCLPDB1"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := parseOracleJDBCURL(tc.url); got != tc.want {
|
||||
t.Errorf("%s: parseOracleJDBCURL(%q) = %+v, want %+v", tc.name, tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOracleJDBCURLRejectsUnknownSchemes(t *testing.T) {
|
||||
for _, url := range []string{
|
||||
"jdbc:postgresql://db/app",
|
||||
"jdbc:mysql://db:3306/app",
|
||||
"",
|
||||
} {
|
||||
if got := parseOracleJDBCURL(url); got != (jdbcURLInfo{}) {
|
||||
t.Errorf("parseOracleJDBCURL(%q) = %+v, want the zero value", url, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleTnsAliasName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"ORCLPDB1", "ORCLPDB1"},
|
||||
{"ORCLPDB1?TNS_ADMIN=C:/wallets", "ORCLPDB1"},
|
||||
{"ORCLPDB1 ", "ORCLPDB1"},
|
||||
{"(DESCRIPTION=(ADDRESS=...))", ""},
|
||||
{"//host:1521/svc", ""},
|
||||
{"host:1521:orcl", ""},
|
||||
{"host/svc", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := oracleTnsAliasName(tc.in); got != tc.want {
|
||||
t.Errorf("oracleTnsAliasName(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build oci
|
||||
|
||||
// Oracle OCI(thick)支持。
|
||||
//
|
||||
// 这个文件只在带上 `oci` 构建标记时参与编译(目前只发布 Windows 版):
|
||||
// godror 通过 CGO 调用 Oracle 客户端,构建期需要 Instant Client 的 SDK
|
||||
// (头文件 + 导入库),运行期只需要 oci.dll —— 由 DBX 注入的 PATH 定位。
|
||||
// thin 版本(CGO_ENABLED=0,跨平台)不包含这个文件,也不会引入任何原生依赖。
|
||||
//
|
||||
// 连接串构造在 oci_dsn.go(无构建标记,CI 的常规测试会覆盖)。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/godror/godror"
|
||||
)
|
||||
|
||||
// ociBuildEnabled 报告当前二进制是否包含 OCI(thick)支持。
|
||||
func ociBuildEnabled() bool { return true }
|
||||
|
||||
// openOCIDB 用 godror(OCI thick 模式)建立连接。
|
||||
//
|
||||
// 连接池参数与 thin 路径保持一致,避免两种驱动在会话数上出现差异。
|
||||
func openOCIDB(params connectParams) (*sql.DB, error) {
|
||||
dsn, err := buildOCIDSN(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("godror", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !oci
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ociBuildEnabled 报告当前二进制是否包含 OCI(thick)支持:
|
||||
// thin 版本(默认构建,跨平台、CGO_ENABLED=0)不包含。
|
||||
func ociBuildEnabled() bool { return false }
|
||||
|
||||
// openOCIDB 在未带 `oci` 构建标记的二进制里给出明确错误。
|
||||
//
|
||||
// 连接级错误会原样回传前端,因此这里说明清楚该装哪个驱动,而不是抛出一个
|
||||
// 难以理解的驱动缺失错误。
|
||||
func openOCIDB(params connectParams) (*sql.DB, error) {
|
||||
return nil, errors.New(
|
||||
"this Oracle agent build does not include OCI support; install the oracle-oci driver (Windows) to use the OCI (thick) mode",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OCI(thick)连接的 DSN 构造。
|
||||
//
|
||||
// 这里不导入 godror、也没有构建标记:除字符串拼装外没有任何依赖,因此
|
||||
// thin 构建与 CI 的常规 `go test` 都能覆盖这些规则。真正加载 Oracle
|
||||
// 客户端的部分在 oci.go(构建标记 oci)。
|
||||
|
||||
// buildOCIDSN 把 DBX 传来的连接信息归一成 godror 的连接串。
|
||||
//
|
||||
// 采用 godror 的 logfmt 形式(含 `connectString=` 即按参数串解析):
|
||||
// 只有 URL 与 logfmt 形式会解析参数,旧式的 `user/pass@connectString` 不会,
|
||||
// 而我们需要下传 timezone(godror 要求显式声明连接时区,否则取 TIMESTAMP
|
||||
// WITH LOCAL TIME ZONE 时无法判断换算基准)。取数批量是 per-statement 选项,
|
||||
// godror 的 DSN 解析器没有 prefetchRows/fetchArraySize 键,不能放进连接串。
|
||||
//
|
||||
// 进程级设置仍由 DBX 注入的启动环境提供:Instant Client 目录在 PATH 上,
|
||||
// TNS_ADMIN 指向 tnsnames.ora 所在目录 —— 两者都在 agent 进程启动时确定,
|
||||
// 连接建立之后无法更改。
|
||||
func buildOCIDSN(params connectParams) (string, error) {
|
||||
target := ociConnectTarget(params)
|
||||
if target == "" {
|
||||
return "", errors.New("OCI connection requires a host with a service name, or a TNS alias")
|
||||
}
|
||||
fields := []string{
|
||||
"connectString=" + ociLogfmtValue(target),
|
||||
"user=" + ociLogfmtValue(params.Username),
|
||||
"password=" + ociLogfmtValue(params.Password),
|
||||
}
|
||||
// 会话时区由客户端(OCI/NLS)确定,显式告知 godror 以本地时区换算,
|
||||
// 消除 “SESSIONTIMEZONE 与 SYSTIMESTAMP 不一致” 的告警。
|
||||
fields = append(fields, "timezone="+ociLogfmtValue("Local"))
|
||||
if params.SysDBA {
|
||||
fields = append(fields, "sysdba=1")
|
||||
}
|
||||
return strings.Join(fields, " "), nil
|
||||
}
|
||||
|
||||
// ociConnectTarget 返回 OCI 连接目标:完整连接描述符或 TNS 别名。
|
||||
//
|
||||
// 优先使用 DBX 下发的连接串(oci8 或 thin 形式,两种都按同一套规则解析),
|
||||
// 没有连接串时退回 host + port + 服务名,保证手工构造的连接也能用。
|
||||
func ociConnectTarget(params connectParams) string {
|
||||
info := parseOracleJDBCURL(params.ConnectionString)
|
||||
switch info.Kind {
|
||||
case "descriptor":
|
||||
return info.Descriptor
|
||||
case "tns":
|
||||
return info.Database
|
||||
case "service":
|
||||
return ociDescriptor(info.Host, info.Port, "SERVICE_NAME", info.Database)
|
||||
case "sid":
|
||||
return ociDescriptor(info.Host, info.Port, "SID", info.Database)
|
||||
}
|
||||
host := strings.TrimSpace(params.Host)
|
||||
database := strings.TrimSpace(params.Database)
|
||||
if host == "" || database == "" {
|
||||
return ""
|
||||
}
|
||||
port := params.Port
|
||||
if port <= 0 {
|
||||
port = 1521
|
||||
}
|
||||
return ociDescriptor(host, port, "SERVICE_NAME", database)
|
||||
}
|
||||
|
||||
func ociDescriptor(host string, port int, key, value string) string {
|
||||
return fmt.Sprintf(
|
||||
"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=%s)(PORT=%d))(CONNECT_DATA=(%s=%s)))",
|
||||
host, port, key, value,
|
||||
)
|
||||
}
|
||||
|
||||
// ociLogfmtValue 按 logfmt 规则给值加引号并转义反斜杠、引号与换行/制表符。
|
||||
// 只做这些可预测的转义(不用 strconv.Quote 的 \xNN/\uNNNN 形式),
|
||||
// 因为 godror 侧的解码器只认常见的反斜杠转义。
|
||||
func ociLogfmtValue(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"\t", `\t`,
|
||||
)
|
||||
return `"` + replacer.Replace(value) + `"`
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 期望的公共尾部:显式时区(godror 的 DSN 解析器没有 prefetchRows/
|
||||
// fetchArraySize 键,取数批量是 per-statement 选项,不进连接串)
|
||||
const ociDSNTail = ` timezone="Local"`
|
||||
|
||||
func TestBuildOCIDSNDerivesDescriptorFromPlainParams(t *testing.T) {
|
||||
dsn, err := buildOCIDSN(connectParams{
|
||||
Host: "db.example.com",
|
||||
Port: 1522,
|
||||
Database: "ORCLPDB1",
|
||||
Username: "scott",
|
||||
Password: "tiger",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := `connectString="(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.example.com)(PORT=1522))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)))"` +
|
||||
` user="scott" password="tiger"` + ociDSNTail
|
||||
if dsn != want {
|
||||
t.Fatalf("dsn = %q, want %q", dsn, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOCIDSNDefaultsThePort(t *testing.T) {
|
||||
dsn, err := buildOCIDSN(connectParams{Host: "h", Database: "svc", Username: "u", Password: "p"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(dsn, "(PORT=1521)") {
|
||||
t.Fatalf("dsn = %q, want the default port 1521", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOCIDSNKeepsTheOci8ConnectionString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
connectionString string
|
||||
wantTarget string
|
||||
}{
|
||||
{
|
||||
name: "service",
|
||||
connectionString: "jdbc:oracle:oci8:@//db.example.com:1521/ORCLPDB1",
|
||||
wantTarget: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)))",
|
||||
},
|
||||
{
|
||||
name: "sid",
|
||||
connectionString: "jdbc:oracle:oci8:@db.example.com:1521:ORCL",
|
||||
wantTarget: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.example.com)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))",
|
||||
},
|
||||
{
|
||||
name: "descriptor",
|
||||
connectionString: "jdbc:oracle:oci8:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1521))(CONNECT_DATA=(SID=x)))",
|
||||
wantTarget: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1521))(CONNECT_DATA=(SID=x)))",
|
||||
},
|
||||
{
|
||||
name: "tns alias",
|
||||
connectionString: "jdbc:oracle:oci8:@ORCLPDB1",
|
||||
wantTarget: "ORCLPDB1",
|
||||
},
|
||||
{
|
||||
name: "thin form is accepted too",
|
||||
connectionString: "jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1",
|
||||
wantTarget: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)))",
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
dsn, err := buildOCIDSN(connectParams{
|
||||
ConnectionString: testCase.connectionString,
|
||||
Username: "u",
|
||||
Password: "p",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := `connectString="` + testCase.wantTarget + `" user="u" password="p"` + ociDSNTail
|
||||
if dsn != want {
|
||||
t.Fatalf("dsn = %q, want %q", dsn, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOCIDSNAddsSysdba(t *testing.T) {
|
||||
dsn, err := buildOCIDSN(connectParams{
|
||||
ConnectionString: "jdbc:oracle:oci8:@ORCLPDB1",
|
||||
Username: "sys",
|
||||
Password: "change_on_install",
|
||||
SysDBA: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dsn, "sysdba=1") {
|
||||
t.Fatalf("dsn = %q, want a sysdba=1 suffix", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOCIDSNQuotesCredentials(t *testing.T) {
|
||||
dsn, err := buildOCIDSN(connectParams{
|
||||
ConnectionString: "jdbc:oracle:oci8:@ORCLPDB1",
|
||||
Username: `do@main\user`,
|
||||
Password: "pa\"ss\nword",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(dsn, `user="do@main\\user"`) {
|
||||
t.Fatalf("dsn = %q, want a quoted, escaped user", dsn)
|
||||
}
|
||||
if !strings.Contains(dsn, `password="pa\"ss\nword"`) {
|
||||
t.Fatalf("dsn = %q, want a quoted, escaped password", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOCIDSNOmitsPerStatementFetchParams(t *testing.T) {
|
||||
dsn, err := buildOCIDSN(connectParams{
|
||||
ConnectionString: "jdbc:oracle:oci8:@ORCLPDB1",
|
||||
Username: "u",
|
||||
Password: "p",
|
||||
URLParams: "prefetch_rows=2000&useSSL=false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(dsn, "prefetchRows=") || strings.Contains(dsn, "fetchArraySize=") {
|
||||
t.Fatalf("dsn = %q, want no per-statement fetch params in the DSN", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOCIDSNRejectsMissingTarget(t *testing.T) {
|
||||
if _, err := buildOCIDSN(connectParams{Username: "u", Password: "p"}); err == nil {
|
||||
t.Fatal("expected an error when neither a connection string nor host+service is given")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIProfileDetection(t *testing.T) {
|
||||
if !usesOCIProfile(connectParams{DriverProfile: "OCI"}) {
|
||||
t.Fatal("the oci driver profile must be detected case-insensitively")
|
||||
}
|
||||
if usesOCIProfile(connectParams{DriverProfile: "oracle"}) || usesOCIProfile(connectParams{}) {
|
||||
t.Fatal("plain Oracle connections must keep using the thin driver")
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,74 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from build_driver_zips import build_driver_zips, remove_raw_driver_artifacts
|
||||
from validate_agents import CRATE_NATIVE_AGENT_MODULES, NATIVE_ONLY_AGENT_MODULES
|
||||
from version_agent_artifacts import NATIVE_DRIVERS, version_agent_artifacts
|
||||
|
||||
|
||||
AGENTS_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def native_only_modules() -> set[str]:
|
||||
declared = {
|
||||
name
|
||||
for name, relative in {**NATIVE_ONLY_AGENT_MODULES, **CRATE_NATIVE_AGENT_MODULES}.items()
|
||||
if (AGENTS_ROOT / relative).exists()
|
||||
}
|
||||
return declared
|
||||
|
||||
|
||||
class NativeReleaseCoverageTest(unittest.TestCase):
|
||||
"""Guards the hand-maintained native module list in the release packaging.
|
||||
|
||||
`version_agent_artifacts` renames `dbx-agent-<module>-<platform>` into the
|
||||
versioned name the registry generator looks for. A native module missing from
|
||||
its list is not a loud failure: the raw artifact is dropped, the module never
|
||||
reaches `agent-registry.json`, and the driver simply disappears from the app.
|
||||
`oracle-oci` was lost exactly that way, so cross-check the list against the
|
||||
modules `validate_agents` treats as native-only rather than trusting it to be
|
||||
maintained by hand.
|
||||
"""
|
||||
|
||||
def test_lists_every_native_only_module(self) -> None:
|
||||
declared = native_only_modules()
|
||||
listed = set(NATIVE_DRIVERS)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(listed - declared),
|
||||
[],
|
||||
"NATIVE_DRIVERS lists entries validate_agents no longer declares native-only",
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(declared - listed),
|
||||
[],
|
||||
"native-only modules missing from version_agent_artifacts.NATIVE_DRIVERS",
|
||||
)
|
||||
|
||||
def test_every_listed_module_has_a_version(self) -> None:
|
||||
versions = json.loads((AGENTS_ROOT / "versions.json").read_text(encoding="utf-8"))
|
||||
|
||||
missing = sorted(name for name in NATIVE_DRIVERS if not versions.get(name))
|
||||
|
||||
self.assertEqual(missing, [], "native modules without a versions.json entry")
|
||||
|
||||
def test_versions_platform_limited_native_artifacts(self) -> None:
|
||||
# A module published for one platform only (Oracle OCI is Windows x64)
|
||||
# must still be versioned; platforms it does not publish are skipped.
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
release_dir = Path(temp_dir)
|
||||
source = release_dir / "dbx-agent-oracle-oci-windows-x64.exe"
|
||||
source.write_bytes(b"MZtest-oracle-oci-agent")
|
||||
versions = {driver: "0.1.0" for driver in NATIVE_DRIVERS}
|
||||
versions["oracle-oci"] = "0.1.7"
|
||||
|
||||
renamed = version_agent_artifacts(release_dir, versions)
|
||||
versioned = release_dir / "dbx-agent-oracle-oci-0.1.7-windows-x64.exe"
|
||||
|
||||
self.assertEqual(renamed, [versioned])
|
||||
self.assertFalse(source.exists())
|
||||
self.assertEqual(versioned.read_bytes(), b"MZtest-oracle-oci-agent")
|
||||
|
||||
|
||||
class DriverReleasePackagesTest(unittest.TestCase):
|
||||
def test_builds_java_and_platform_specific_native_driver_tar_zstd_packages(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -41,6 +106,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
|
||||
versions = {
|
||||
"h2": "0.2.5",
|
||||
"oracle": "0.1.10",
|
||||
"oracle-oci": "0.1.0",
|
||||
"xugu": "0.1.20",
|
||||
"kingbase": "0.1.34",
|
||||
"iotdb": "0.1.30",
|
||||
|
||||
@@ -18,6 +18,7 @@ NATIVE_ONLY_AGENT_MODULES = {
|
||||
"hive": "drivers/hive-go",
|
||||
"argo": "drivers/argo-go",
|
||||
"oracle": "drivers/oracle-go",
|
||||
"oracle-oci": "drivers/oracle-go",
|
||||
"kingbase": "drivers/kingbase-go",
|
||||
"iotdb": "drivers/iotdb",
|
||||
"neo4j": "drivers/neo4j-go",
|
||||
|
||||
@@ -171,7 +171,7 @@ class ValidateAgentsTest(unittest.TestCase):
|
||||
):
|
||||
(root / "drivers" / driver).mkdir(parents=True)
|
||||
(root / "versions.json").write_text(
|
||||
json.dumps({"h2": "0.1.0", "cassandra": "0.1.0", "hive": "0.1.0", "oracle": "0.1.0", "kingbase": "0.1.0", "iotdb": "0.1.0", "neo4j": "0.1.0", "vastbase": "0.1.0", "xugu": "0.1.0", "rabbitmq": "0.1.0", "rocketmq": "0.1.0", "zookeeper": "0.1.0", "tdengine": "0.1.0"}),
|
||||
json.dumps({"h2": "0.1.0", "cassandra": "0.1.0", "hive": "0.1.0", "oracle": "0.1.0", "oracle-oci": "0.1.0", "kingbase": "0.1.0", "iotdb": "0.1.0", "neo4j": "0.1.0", "vastbase": "0.1.0", "xugu": "0.1.0", "rabbitmq": "0.1.0", "rocketmq": "0.1.0", "zookeeper": "0.1.0", "tdengine": "0.1.0"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NATIVE_DRIVERS = ("cassandra", "hive", "argo", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "duckdb", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker")
|
||||
# Every module that publishes platform-native agent binaries, keyed by its
|
||||
# artifact name. `oracle-oci` is built from the same `oracle-go` module as
|
||||
# `oracle` with the `oci` build tag, so it needs its own entry here: the release
|
||||
# job stages it as `dbx-agent-oracle-oci-<platform>`, and only a versioned name
|
||||
# is picked up by the registry generator. Platforms it does not publish are
|
||||
# skipped by `rename_artifact`, which keeps Windows-only drivers like
|
||||
# `oracle-oci` working without special casing.
|
||||
NATIVE_DRIVERS = ("cassandra", "hive", "argo", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "duckdb", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker")
|
||||
PLATFORMS = (
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"nebula": "0.1.0",
|
||||
"oceanbase-oracle": "0.1.68",
|
||||
"oracle": "0.1.66",
|
||||
"oracle-oci": "0.1.0",
|
||||
"snowflake": "0.1.71",
|
||||
"sqlite-worker": "0.1.5",
|
||||
"spanner": "0.1.24",
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from "@/lib/plugins/frontendPlugin";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { isWindows } from "@/lib/backend/platform";
|
||||
import { applyMeilisearchBasePathToExternalConfig, applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl";
|
||||
import { hasXuguConnectionDatabase } from "@/lib/connection/xuguDatabase";
|
||||
import { DEFAULT_QUERY_TIMEOUT_SECS, MAX_CONNECT_TIMEOUT_SECS, MAX_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits";
|
||||
@@ -141,6 +142,7 @@ import {
|
||||
Pipette,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
@@ -422,6 +424,8 @@ const defaultForm = (): ConnectionForm => ({
|
||||
client_cert_path: "",
|
||||
client_key_path: "",
|
||||
sysdba: false,
|
||||
oracle_oci_nls_lang: "",
|
||||
oracle_oci_tns_admin: "",
|
||||
oracle_connection_type: "service_name",
|
||||
connection_string: undefined,
|
||||
jdbc_driver_class: undefined,
|
||||
@@ -807,6 +811,8 @@ const appliedConnectionUrlInput = ref("");
|
||||
const meilisearchHostInput = ref("");
|
||||
const appliedMeilisearchHostInput = ref("");
|
||||
const oracleTnsAdminPath = ref("");
|
||||
/** 每次进入 OCI 模式只提醒一次 Instant Client 目录,避免反复点“测试”时刷屏。 */
|
||||
const oracleOciClientPathReminded = ref(false);
|
||||
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
|
||||
const h2ConnectionMode = ref<H2ConnectionMode>("file");
|
||||
const dremioConnectionMode = ref<DremioConnectionMode>("legacy");
|
||||
@@ -3105,6 +3111,8 @@ watch(
|
||||
idle_timeout_secs: config.idle_timeout_secs ?? 60,
|
||||
keepalive_interval_secs: config.keepalive_interval_secs ?? 30,
|
||||
sysdba: config.sysdba || isOracleSysUser(config),
|
||||
oracle_oci_nls_lang: config.oracle_oci_nls_lang || "",
|
||||
oracle_oci_tns_admin: config.oracle_oci_tns_admin || "",
|
||||
oracle_connection_type: config.oracle_connection_type || "service_name",
|
||||
connection_string: config.connection_string,
|
||||
jdbc_driver_class: config.db_type === "sundb" ? sundbJdbcDriverClass(config) : config.jdbc_driver_class,
|
||||
@@ -3467,6 +3475,156 @@ function switchH2DriverProfile(profile: "h2" | "h2-v1" | "h2-v2" | "h2-v3" | "h2
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Oracle driver mode: thin (go-ora agent, default) or OCI (thick driver,
|
||||
* requires a globally configured Oracle Instant Client). The oci.dll path
|
||||
* lives in the global settings on purpose — one configuration is shared by
|
||||
* every OCI connection, and new OCI connections backfill it automatically.
|
||||
*/
|
||||
// OCI(thick)驱动目前只发布 Windows x64 产物:非 Windows 平台不显示模式切换,
|
||||
// 避免用户选到无法安装的驱动。已保存的 OCI 连接仍按原样打开(连接时会得到
|
||||
// 明确的“驱动未安装”错误),并把 Thin 按钮留在原地便于切回。
|
||||
const oracleOciDriverSelectable = isWindows();
|
||||
|
||||
function switchOracleDriverMode(mode: "thin" | "oci") {
|
||||
form.value.driver_profile = mode === "oci" ? "oci" : "oracle";
|
||||
oracleOciClientPathReminded.value = false;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function persistOracleOciClientPath() {
|
||||
void settingsStore.updateEditorSettings({
|
||||
oracleOciClientPath: settingsStore.editorSettings.oracleOciClientPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* NLS_LANG is a per-connection override: an empty value follows the global
|
||||
* default, a filled value makes this connection own a dedicated agent process
|
||||
* (the variable is process-scoped for OCI). The dropdown lists the common
|
||||
* client character sets; the save button promotes the current value to the
|
||||
* global default for reuse.
|
||||
*/
|
||||
const OCI_NLS_LANG_FOLLOW_GLOBAL = "__follow_global__";
|
||||
const ORACLE_NLS_LANG_OPTIONS = [
|
||||
"AMERICAN_AMERICA.AL32UTF8",
|
||||
"AMERICAN_AMERICA.UTF8",
|
||||
"AMERICAN_AMERICA.WE8ISO8859P1",
|
||||
"AMERICAN_AMERICA.ZHS16GBK",
|
||||
"SIMPLIFIED CHINESE_CHINA.AL32UTF8",
|
||||
"SIMPLIFIED CHINESE_CHINA.ZHS16GBK",
|
||||
"TRADITIONAL CHINESE_TAIWAN.AL32UTF8",
|
||||
"TRADITIONAL CHINESE_TAIWAN.ZHT16MSWIN950",
|
||||
"JAPANESE_JAPAN.AL32UTF8",
|
||||
"JAPANESE_JAPAN.JA16SJIS",
|
||||
"KOREAN_KOREA.AL32UTF8",
|
||||
"KOREAN_KOREA.KO16MSWIN949",
|
||||
] as const;
|
||||
|
||||
const oracleOciNlsLangSelection = computed(() => (form.value.oracle_oci_nls_lang?.trim() ? form.value.oracle_oci_nls_lang.trim() : OCI_NLS_LANG_FOLLOW_GLOBAL));
|
||||
|
||||
/** A stored value that predates the preset list must stay selectable. */
|
||||
const oracleOciNlsLangCustomValue = computed(() => {
|
||||
const value = form.value.oracle_oci_nls_lang?.trim();
|
||||
if (!value) return null;
|
||||
return (ORACLE_NLS_LANG_OPTIONS as readonly string[]).includes(value) ? null : value;
|
||||
});
|
||||
|
||||
const oracleOciNlsLangPlaceholder = computed(() => {
|
||||
const globalValue = settingsStore.editorSettings.oracleOciNlsLang?.trim();
|
||||
return globalValue ? t("connection.oracleOciNlsLangPlaceholderGlobal", { value: globalValue }) : t("connection.oracleOciNlsLangPlaceholder");
|
||||
});
|
||||
|
||||
/** Names the global default in effect, so promoting a value becomes visible in place. */
|
||||
const oracleOciNlsLangFollowGlobalLabel = computed(() => {
|
||||
const globalValue = settingsStore.editorSettings.oracleOciNlsLang?.trim();
|
||||
return globalValue ? t("connection.oracleOciNlsLangFollowGlobalValue", { value: globalValue }) : t("connection.oracleOciNlsLangFollowGlobal");
|
||||
});
|
||||
|
||||
function onOciNlsLangSelected(value: unknown) {
|
||||
const selected = typeof value === "string" ? value : "";
|
||||
form.value.oracle_oci_nls_lang = selected === OCI_NLS_LANG_FOLLOW_GLOBAL ? "" : selected;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
async function saveOciNlsLangAsGlobalDefault() {
|
||||
const value = form.value.oracle_oci_nls_lang?.trim();
|
||||
if (!value) {
|
||||
toast(t("connection.oracleOciNlsLangSaveGlobalEmpty"), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await settingsStore.updateEditorSettingsAndPersist({ oracleOciNlsLang: value });
|
||||
toast(t("connection.oracleOciNlsLangSavedGlobal", { value }), 3000);
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接级 TNS_ADMIN(tnsnames.ora / sqlnet.ora / 钱包目录):留空跟随全局默认,
|
||||
* 填写后本连接使用自己的目录——ADB 钱包、sqlnet.ora 网络选项因此不依赖
|
||||
* TNS 连接方式。目录同样是进程级的,随 agent 启动注入。
|
||||
*/
|
||||
const oracleOciTnsAdminPlaceholder = computed(() => {
|
||||
const globalValue = settingsStore.editorSettings.oracleOciTnsAdmin?.trim();
|
||||
return globalValue ? t("connection.oracleOciTnsAdminPlaceholderGlobal", { value: globalValue }) : t("connection.oracleTnsAdminPlaceholder");
|
||||
});
|
||||
|
||||
async function saveOciTnsAdminAsGlobalDefault() {
|
||||
const value = form.value.oracle_oci_tns_admin?.trim();
|
||||
if (!value) {
|
||||
toast(t("connection.oracleOciTnsAdminSaveGlobalEmpty"), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await settingsStore.updateEditorSettingsAndPersist({ oracleOciTnsAdmin: value });
|
||||
toast(t("connection.oracleOciTnsAdminSavedGlobal", { value }), 3000);
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function browseOciTnsAdminDirectory() {
|
||||
if (!isTauriRuntime()) {
|
||||
toast(t("connection.oraclePathPickerDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title: t("connection.oracleTnsAdminBrowse"),
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (typeof selected === "string") {
|
||||
form.value.oracle_oci_tns_admin = selected;
|
||||
resetTestState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for the Oracle Instant Client directory. The desktop shell is the
|
||||
* only runtime that can resolve a local path, so the picker explains itself
|
||||
* on the web build instead of silently doing nothing.
|
||||
*/
|
||||
async function browseOciClientDirectory() {
|
||||
if (!isTauriRuntime()) {
|
||||
toast(t("connection.oraclePathPickerDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title: t("connection.oracleOciPathBrowse"),
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (typeof selected === "string") {
|
||||
settingsStore.editorSettings.oracleOciClientPath = selected;
|
||||
persistOracleOciClientPath();
|
||||
resetTestState();
|
||||
}
|
||||
}
|
||||
|
||||
const damengDriverMode = computed(() => damengDriverModeForConfig(form.value));
|
||||
const isDamengCustomDriver = computed(() => form.value.db_type === "dameng" && damengDriverMode.value === "custom");
|
||||
|
||||
@@ -4718,6 +4876,11 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
// service, SID, and descriptor JDBC strings exactly as before.
|
||||
config.connection_string = undefined;
|
||||
}
|
||||
if (config.db_type === "oracle" && config.driver_profile === "oci" && !settingsStore.editorSettings.oracleOciClientPath?.trim() && !oracleOciClientPathReminded.value) {
|
||||
// 非阻断提醒:没有配置 Instant Client 目录时,agent 进程只能依赖系统 PATH 里已有的 oci.dll。
|
||||
oracleOciClientPathReminded.value = true;
|
||||
toast(t("connection.oracleOciClientPathMissing"), 5000);
|
||||
}
|
||||
normalizeConnectionTimeouts(config, editGlobalConnectTimeoutSecs.value, editGlobalQueryTimeoutSecs.value);
|
||||
if (config.db_type === "manticoresearch") {
|
||||
config.url_params = "";
|
||||
@@ -4947,9 +5110,25 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
if (config.db_type !== "oracle") {
|
||||
config.sysdba = undefined;
|
||||
config.oracle_connection_type = undefined;
|
||||
config.oracle_oci_nls_lang = undefined;
|
||||
config.oracle_oci_tns_admin = undefined;
|
||||
} else {
|
||||
config.sysdba = !!config.sysdba || isOracleSysUser(config);
|
||||
config.oracle_connection_type = config.oracle_connection_type || "service_name";
|
||||
// Driver mode is encoded as the driver profile so the agent router picks
|
||||
// the OCI (thick) agent; everything else stays on the thin agent.
|
||||
if (config.driver_profile === "oci") {
|
||||
config.driver_label = "Oracle (OCI)";
|
||||
config.oracle_oci_nls_lang = config.oracle_oci_nls_lang?.trim() || undefined;
|
||||
config.oracle_oci_tns_admin = config.oracle_oci_tns_admin?.trim() || undefined;
|
||||
} else {
|
||||
// Only default when empty: saved legacy profiles (oracle-legacy,
|
||||
// oracle-10g) must survive edits untouched.
|
||||
if (!config.driver_profile) config.driver_profile = "oracle";
|
||||
config.driver_label = "Oracle";
|
||||
config.oracle_oci_nls_lang = undefined;
|
||||
config.oracle_oci_tns_admin = undefined;
|
||||
}
|
||||
}
|
||||
if (config.db_type !== "redis") {
|
||||
config.redis_connection_mode = undefined;
|
||||
@@ -6639,12 +6818,17 @@ async function browseHiveKerberosFile(target: "krb5" | "jaas") {
|
||||
}
|
||||
|
||||
async function browseOracleTnsNamesFile() {
|
||||
if (!isTauriRuntime()) return;
|
||||
// TNS_ADMIN is a directory; accept a folder pick. Desktop-only, so the web
|
||||
// build gets an explanation instead of a silent no-op.
|
||||
if (!isTauriRuntime()) {
|
||||
toast(t("connection.oraclePathPickerDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title: t("connection.oracleTnsAdminBrowse"),
|
||||
directory: true,
|
||||
multiple: false,
|
||||
filters: [{ name: "Oracle TNS names", extensions: ["ora"] }],
|
||||
});
|
||||
if (typeof selected === "string") {
|
||||
oracleTnsAdminPath.value = normalizeOracleTnsAdminPath(selected);
|
||||
@@ -8938,11 +9122,99 @@ function openExternalUrl(url: string) {
|
||||
<SpannerConnectionFields v-model:database="form.database" @change="resetTestState" />
|
||||
</template>
|
||||
|
||||
<div v-if="form.db_type === 'oracle' && (oracleOciDriverSelectable || form.driver_profile === 'oci')" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.oracleDriverMode") }}</Label>
|
||||
<div class="col-span-3 flex gap-2">
|
||||
<Button size="sm" :variant="form.driver_profile !== 'oci' ? 'default' : 'outline'" @click="switchOracleDriverMode('thin')"> Thin </Button>
|
||||
<Button v-if="oracleOciDriverSelectable" size="sm" :variant="form.driver_profile === 'oci' ? 'default' : 'outline'" @click="switchOracleDriverMode('oci')"> OCI </Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="form.db_type === 'oracle' && form.driver_profile === 'oci'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">oci.dll</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="settingsStore.editorSettings.oracleOciClientPath" class="flex-1 font-mono" :placeholder="t('connection.oracleOciPathPlaceholder')" @change="persistOracleOciClientPath" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseOciClientDirectory">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.oracleOciPathBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleOciPathHint") }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">NLS_LANG</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Select :model-value="oracleOciNlsLangSelection" @update:model-value="onOciNlsLangSelected">
|
||||
<SelectTrigger class="flex-1 font-mono" aria-label="NLS_LANG">
|
||||
<SelectValue :placeholder="oracleOciNlsLangPlaceholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="OCI_NLS_LANG_FOLLOW_GLOBAL">
|
||||
{{ oracleOciNlsLangFollowGlobalLabel }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="option in ORACLE_NLS_LANG_OPTIONS" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</SelectItem>
|
||||
<SelectItem v-if="oracleOciNlsLangCustomValue" :value="oracleOciNlsLangCustomValue">
|
||||
{{ oracleOciNlsLangCustomValue }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :aria-label="t('connection.oracleOciNlsLangSaveGlobal')" @click="saveOciNlsLangAsGlobalDefault">
|
||||
<Save class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.oracleOciNlsLangSaveGlobal") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleOciNlsLangHint") }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="form.oracle_oci_tns_admin" class="flex-1 font-mono" :placeholder="oracleOciTnsAdminPlaceholder" @change="resetTestState" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :aria-label="t('connection.oracleTnsAdminBrowse')" @click="browseOciTnsAdminDirectory">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.oracleTnsAdminBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :aria-label="t('connection.oracleOciTnsAdminSaveGlobal')" @click="saveOciTnsAdminAsGlobalDefault">
|
||||
<Save class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.oracleOciTnsAdminSaveGlobal") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleOciTnsAdminHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="oracleTnsAdminPath" class="flex-1" :placeholder="t('connection.oracleTnsAdminPlaceholder')" />
|
||||
<Tooltip v-if="isDesktop">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseOracleTnsNamesFile">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const { store, settings, backend, platformState } = vi.hoisted(() => ({
|
||||
store: {
|
||||
connectionGroupOptions: [],
|
||||
newConnectionGroupId: null,
|
||||
selectedConnectionGroupId: null,
|
||||
addConnection: vi.fn(),
|
||||
updateConnection: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
addEphemeralConnection: vi.fn(),
|
||||
removeConnection: vi.fn(),
|
||||
startEditing: vi.fn(),
|
||||
stopEditing: vi.fn(),
|
||||
clearConnectionError: vi.fn(),
|
||||
updateConnectionDatabaseInfo: vi.fn(),
|
||||
applyGlobalTimeouts: vi.fn((config) => config),
|
||||
getConfig: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 },
|
||||
rememberedDatabaseForConnection: vi.fn(() => ""),
|
||||
persistEditorSettings: vi.fn(),
|
||||
updateEditorSettings: vi.fn(),
|
||||
updateEditorSettingsAndPersist: vi.fn(),
|
||||
},
|
||||
backend: {
|
||||
connectDb: vi.fn(),
|
||||
disconnectDb: vi.fn(),
|
||||
testConnectionWithInfo: vi.fn(),
|
||||
listDatabases: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listJdbcDrivers: vi.fn(),
|
||||
listJdbcMavenBundles: vi.fn(),
|
||||
listJdbcLocalBundles: vi.fn(),
|
||||
listSshConfigHosts: vi.fn(),
|
||||
listInstalledAgentsLocal: vi.fn(),
|
||||
listenAgentInstallProgress: vi.fn(),
|
||||
},
|
||||
platformState: { windows: true },
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" }));
|
||||
vi.mock("@/stores/settingsStore", async (original) => ({ ...(await original<typeof import("@/stores/settingsStore")>()), useSettingsStore: () => settings }));
|
||||
vi.mock("@/stores/tunnelProfileStore", () => ({ useTunnelProfileStore: () => ({ profiles: [], profileById: () => undefined, init: vi.fn() }) }));
|
||||
vi.mock("@/lib/backend/api", async (original) => ({ ...(await original<typeof import("@/lib/backend/api")>()), ...backend }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/lib/backend/platform", async (original) => ({
|
||||
...(await original<typeof import("@/lib/backend/platform")>()),
|
||||
isWindows: () => platformState.windows,
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const Dialog = defineComponent({
|
||||
props: { open: Boolean },
|
||||
emits: ["update:open"],
|
||||
setup:
|
||||
(props, { slots, emit }) =>
|
||||
() =>
|
||||
props.open ? h("section", [h("button", { "data-testid": "dismiss-dialog", onClick: () => emit("update:open", false) }, "Dismiss"), slots.default?.()]) : null,
|
||||
});
|
||||
return { Dialog, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
|
||||
});
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("button", attrs, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
setup:
|
||||
(props, { attrs, emit }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/PasswordInput.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
setup:
|
||||
(props, { attrs }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, type: "password", value: props.modelValue }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
props: ["value"],
|
||||
setup:
|
||||
(props, { slots }) =>
|
||||
() =>
|
||||
props.value === "connection" ? h("div", slots.default?.()) : null,
|
||||
});
|
||||
return { Tabs: pass, TabsContent, TabsList: pass, TabsTrigger: pass };
|
||||
});
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("span", slots.default?.()),
|
||||
});
|
||||
return { HelpTooltip: pass, Tooltip: pass, TooltipContent: pass, TooltipTrigger: pass };
|
||||
});
|
||||
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function oracleConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "oracle-conn",
|
||||
name: "Oracle connection",
|
||||
db_type: "oracle",
|
||||
driver_profile: "oracle",
|
||||
driver_label: "Oracle",
|
||||
host: "oracle.example.test",
|
||||
port: 1521,
|
||||
username: "dbx_test",
|
||||
password: "secret",
|
||||
database: "XEPDB1",
|
||||
save_password: true,
|
||||
...overrides,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 8; i++) await nextTick();
|
||||
}
|
||||
|
||||
async function mountDialog(config: ConnectionConfig) {
|
||||
const props = reactive({ open: true, editConfig: config });
|
||||
store.getConfig.mockReturnValue(config);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ConnectionDialog, {
|
||||
...props,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
props.open = value;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(i18n);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await settle();
|
||||
return props;
|
||||
}
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement | undefined {
|
||||
return [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
platformState.windows = true;
|
||||
i18n.global.locale.value = "en";
|
||||
for (const fn of Object.values(backend)) fn.mockResolvedValue([]);
|
||||
backend.listenAgentInstallProgress.mockResolvedValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("Oracle driver mode availability", () => {
|
||||
it("shows the OCI switch on Windows", async () => {
|
||||
platformState.windows = true;
|
||||
await mountDialog(oracleConnection());
|
||||
|
||||
expect(buttonByText("Thin")).toBeTruthy();
|
||||
expect(buttonByText("OCI")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the whole mode row for plain Oracle connections on non-Windows platforms", async () => {
|
||||
platformState.windows = false;
|
||||
await mountDialog(oracleConnection());
|
||||
|
||||
expect(buttonByText("OCI")).toBeUndefined();
|
||||
expect(buttonByText("Thin")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the mode row for saved OCI connections on non-Windows platforms so they can switch back", async () => {
|
||||
platformState.windows = false;
|
||||
await mountDialog(oracleConnection({ driver_profile: "oci", driver_label: "Oracle (OCI)" }));
|
||||
|
||||
expect(buttonByText("OCI")).toBeUndefined();
|
||||
expect(buttonByText("Thin")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -652,6 +652,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "Xidmətin adı",
|
||||
oracleTnsAlias: "Şəbəkə ləqəbi",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora faylındakı ləqəb",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "tnsnames.ora olan qovluq",
|
||||
oracleTnsAdminBrowse: "tnsnames.ora faylını seçin",
|
||||
oracleTnsPathHint: "Bu yolu DBX server hissəsi/Oracle agenti oxuyur. Masaüstü yollar yerlidir; veb və Docker yolları serverdədir.",
|
||||
|
||||
@@ -889,8 +889,27 @@ export default {
|
||||
serviceNameOnly: "Service Name",
|
||||
oracleTnsAlias: "Network Alias",
|
||||
oracleTnsAliasPlaceholder: "Alias from tnsnames.ora",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "Directory containing tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Select tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Select the TNS_ADMIN directory",
|
||||
oracleTnsPathHint: "The DBX backend/Oracle agent reads this path. Desktop paths are local; Web and Docker paths are on the server.",
|
||||
oracleTnsAliasRequired: "Oracle TNS mode requires a network alias.",
|
||||
oracleTnsAdminRequired: "Oracle TNS mode requires the TNS_ADMIN directory containing tnsnames.ora.",
|
||||
|
||||
@@ -880,6 +880,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "Nombre de servicio",
|
||||
oracleTnsAlias: "Alias de red",
|
||||
oracleTnsAliasPlaceholder: "Alias de tnsnames.ora",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "Directorio que contiene tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Seleccionar tnsnames.ora",
|
||||
oracleTnsPathHint: "La ruta la lee el backend de DBX/Oracle Agent. En escritorio es local; en Web y Docker pertenece al servidor.",
|
||||
|
||||
@@ -874,6 +874,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "Nome Servizio",
|
||||
oracleTnsAlias: "Alias di rete",
|
||||
oracleTnsAliasPlaceholder: "Alias da tnsnames.ora",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "Directory contenente tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Seleziona tnsnames.ora",
|
||||
oracleTnsPathHint: "Il percorso viene letto dal backend DBX/Oracle Agent. Sul desktop è locale; su Web e Docker appartiene al server.",
|
||||
|
||||
@@ -874,6 +874,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "サービス名",
|
||||
oracleTnsAlias: "ネットワーク・エイリアス",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora のエイリアス",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "tnsnames.ora を含むディレクトリ",
|
||||
oracleTnsAdminBrowse: "tnsnames.ora を選択",
|
||||
oracleTnsPathHint: "このパスは DBX バックエンド/Oracle Agent が読み取ります。デスクトップではローカルパス、Web/Docker ではサーバーパスを指定してください。",
|
||||
|
||||
@@ -876,6 +876,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "서비스 이름",
|
||||
oracleTnsAlias: "네트워크 별칭",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora의 별칭",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "tnsnames.ora가 있는 디렉터리",
|
||||
oracleTnsAdminBrowse: "tnsnames.ora 선택",
|
||||
oracleTnsPathHint: "DBX 백엔드/Oracle 에이전트가 이 경로를 읽습니다. 데스크톱 경로는 로컬이며, Web과 Docker 경로는 서버에 있습니다.",
|
||||
|
||||
@@ -875,6 +875,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "Service Name",
|
||||
oracleTnsAlias: "Alias de rede",
|
||||
oracleTnsAliasPlaceholder: "Alias do tnsnames.ora",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "Diretório que contém tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Selecionar tnsnames.ora",
|
||||
oracleTnsPathHint: "O caminho é lido pelo backend do DBX/Oracle Agent. No desktop é local; no Web e Docker pertence ao servidor.",
|
||||
|
||||
@@ -833,6 +833,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "Имя службы",
|
||||
oracleTnsAlias: "Сетевой псевдоним",
|
||||
oracleTnsAliasPlaceholder: "Псевдоним из tnsnames.ora",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "Каталог, содержащий tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Выбрать tnsnames.ora",
|
||||
oracleTnsPathHint: "Этот путь читает бэкенд DBX / агент Oracle. В настольной версии пути локальные; в Web и Docker — на сервере.",
|
||||
|
||||
@@ -654,6 +654,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "Servis Adı",
|
||||
oracleTnsAlias: "Ağ Takma Adı",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora dosyasındaki takma ad",
|
||||
oracleDriverMode: "Driver mode",
|
||||
oracleOciPathPlaceholder: "Directory containing oci.dll",
|
||||
oracleOciPathBrowse: "Select the Oracle Instant Client directory",
|
||||
oracleOciPathHint: "The Instant Client location is a global setting shared by every OCI connection; new OCI connections reuse it automatically.",
|
||||
oracleOciClientPathMissing: "No Oracle Instant Client directory is configured. An OCI connection may fail to load oci.dll — set the directory that contains it under the driver mode.",
|
||||
oracleOciNlsLangPlaceholder: "Select a client character set",
|
||||
oracleOciNlsLangFollowGlobal: "Follow the global default (empty)",
|
||||
oracleOciNlsLangFollowGlobalValue: "Follows the global default: {value}",
|
||||
oraclePathPickerDesktopOnly: "Picking a local path is only available in the desktop app.",
|
||||
oracleOciNlsLangPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciNlsLangSaveGlobal: "Save as the global default",
|
||||
oracleOciNlsLangSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "Follows the global default: {value}",
|
||||
oracleOciTnsAdminHint: "Connection-level TNS_ADMIN: the directory holding tnsnames.ora / sqlnet.ora / the wallet; empty follows the global default. ADB wallets and sqlnet.ora network options work without switching to the TNS connection form.",
|
||||
oracleOciTnsAdminSaveGlobal: "Save as the global default",
|
||||
oracleOciTnsAdminSavedGlobal: "Saved as the global default: {value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "Enter a TNS_ADMIN directory to save first",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "Select an NLS_LANG to save first",
|
||||
oracleOciNlsLangHint: "Connection-level client character set (NLS_LANG) for OCI; overriding it gives this connection its own agent process. Set AL32UTF8 to match a UTF-8 database and avoid mojibake.",
|
||||
oracleTnsAdminPlaceholder: "tnsnames.ora dosyasını içeren dizin",
|
||||
oracleTnsAdminBrowse: "tnsnames.ora seçin",
|
||||
oracleTnsPathHint: "Bu yolu DBX arka ucu/Oracle agent'ı okur. Masaüstü yolları yereldir; Web ve Docker yolları sunucudadır.",
|
||||
|
||||
@@ -805,8 +805,27 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "服务名",
|
||||
oracleTnsAlias: "网络别名",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora 中的别名",
|
||||
oracleDriverMode: "驱动模式",
|
||||
oracleOciPathPlaceholder: "包含 oci.dll 的目录",
|
||||
oracleOciPathBrowse: "选择 Oracle Instant Client 目录",
|
||||
oracleOciPathHint: "Instant Client 路径为全局配置,所有 OCI 连接共用;新增 OCI 连接会自动带出此路径。",
|
||||
oracleOciClientPathMissing: "尚未配置 Oracle Instant Client 目录,OCI 连接可能无法加载 oci.dll;请在“驱动模式”下填写 oci.dll 所在目录。",
|
||||
oracleOciNlsLangPlaceholder: "选择客户端字符集",
|
||||
oracleOciNlsLangFollowGlobal: "跟随全局默认(留空)",
|
||||
oracleOciNlsLangFollowGlobalValue: "跟随全局默认:{value}",
|
||||
oraclePathPickerDesktopOnly: "本地路径选择仅在桌面版可用。",
|
||||
oracleOciNlsLangPlaceholderGlobal: "跟随全局默认:{value}",
|
||||
oracleOciNlsLangSaveGlobal: "保存为全局默认",
|
||||
oracleOciNlsLangSavedGlobal: "已保存为全局默认:{value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "跟随全局默认:{value}",
|
||||
oracleOciTnsAdminHint: "连接级 TNS_ADMIN:指向包含 tnsnames.ora / sqlnet.ora / 钱包的目录;留空跟随全局默认。ADB 钱包与 sqlnet.ora 网络选项因此无需切换到 TNS 连接方式。",
|
||||
oracleOciTnsAdminSaveGlobal: "保存为全局默认",
|
||||
oracleOciTnsAdminSavedGlobal: "已保存为全局默认:{value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "请先填写要保存的 TNS_ADMIN 目录",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "请先选择要保存的 NLS_LANG",
|
||||
oracleOciNlsLangHint: "连接级 OCI 客户端字符集(NLS_LANG):填写后将为此连接启用独立 agent 进程;数据库为 UTF-8 时建议设为 AL32UTF8,避免中文乱码。",
|
||||
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目录",
|
||||
oracleTnsAdminBrowse: "选择 tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "选择 TNS_ADMIN 目录",
|
||||
oracleTnsPathHint: "该路径由 DBX 后端/Oracle Agent 读取。桌面端填写本机路径,Web 和 Docker 部署填写服务器路径。",
|
||||
oracleTnsAliasRequired: "Oracle TNS 模式需要填写网络别名。",
|
||||
oracleTnsAdminRequired: "Oracle TNS 模式需要填写包含 tnsnames.ora 的 TNS_ADMIN 目录。",
|
||||
|
||||
@@ -878,6 +878,25 @@ export default withEnglishFallback({
|
||||
serviceNameOnly: "服務名稱",
|
||||
oracleTnsAlias: "網路別名",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora 中的別名",
|
||||
oracleDriverMode: "驱动模式",
|
||||
oracleOciPathPlaceholder: "包含 oci.dll 的目录",
|
||||
oracleOciPathBrowse: "选择 Oracle Instant Client 目录",
|
||||
oracleOciPathHint: "Instant Client 路径为全局配置,所有 OCI 连接共用;新增 OCI 连接会自动带出此路径。",
|
||||
oracleOciClientPathMissing: "尚未配置 Oracle Instant Client 目录,OCI 连接可能无法加载 oci.dll;请在“驱动模式”下填写 oci.dll 所在目录。",
|
||||
oracleOciNlsLangPlaceholder: "选择客户端字符集",
|
||||
oracleOciNlsLangFollowGlobal: "跟随全局默认(留空)",
|
||||
oracleOciNlsLangFollowGlobalValue: "跟随全局默认:{value}",
|
||||
oraclePathPickerDesktopOnly: "本地路径选择仅在桌面版可用。",
|
||||
oracleOciNlsLangPlaceholderGlobal: "跟随全局默认:{value}",
|
||||
oracleOciNlsLangSaveGlobal: "保存为全局默认",
|
||||
oracleOciNlsLangSavedGlobal: "已保存为全局默认:{value}",
|
||||
oracleOciTnsAdminPlaceholderGlobal: "跟随全局默认:{value}",
|
||||
oracleOciTnsAdminHint: "连接级 TNS_ADMIN:指向包含 tnsnames.ora / sqlnet.ora / 钱包的目录;留空跟随全局默认。ADB 钱包与 sqlnet.ora 网络选项因此无需切换到 TNS 连接方式。",
|
||||
oracleOciTnsAdminSaveGlobal: "保存为全局默认",
|
||||
oracleOciTnsAdminSavedGlobal: "已保存为全局默认:{value}",
|
||||
oracleOciTnsAdminSaveGlobalEmpty: "请先填写要保存的 TNS_ADMIN 目录",
|
||||
oracleOciNlsLangSaveGlobalEmpty: "请先选择要保存的 NLS_LANG",
|
||||
oracleOciNlsLangHint: "连接级 OCI 客户端字符集(NLS_LANG):填写后将为此连接启用独立 agent 进程;数据库为 UTF-8 时建议设为 AL32UTF8,避免中文乱码。",
|
||||
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目錄",
|
||||
oracleTnsAdminBrowse: "選擇 tnsnames.ora",
|
||||
oracleTnsPathHint: "此路徑由 DBX 後端/Oracle Agent 讀取。桌面版填寫本機路徑,Web 和 Docker 部署填寫伺服器路徑。",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { AGENT_ACTIONS, ASK_ACTIONS, buildAgentRequest, buildSystemPrompt, isValidActionForMode, type AiAction, type AiContext } from "@/lib/ai/ai";
|
||||
import { INTENT_CLASSIFY_MAX_TEXT_CHARS, actionForIntent, classifyIntentByLlm, intentCandidatesForMode, parseClassifierAction, requestsExplicitExecution, routeIntent, routeIntentByRules, type AiIntent, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
|
||||
@@ -26,6 +27,11 @@ function context(overrides: Partial<AiContext> = {}): AiContext {
|
||||
/** Gate-open sample: an unambiguous data request (查一下 anchors the execution gate). */
|
||||
const EXPLICIT_DATA_TEXT = "查一下这张表里的数据";
|
||||
|
||||
// 提示词断言按英文文案书写;显式固定语言,避免跟随机器语言环境(例如中文系统)而误报。
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
describe("routeIntentByRules — Ask mode golden cases", () => {
|
||||
// Every non-general Ask action is reachable from an unambiguous zh/en sample.
|
||||
it("routes SQL-production requests to generate", () => {
|
||||
|
||||
@@ -47,6 +47,21 @@ describe("NebulaGraph driver installation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Oracle driver installation", () => {
|
||||
it("routes the OCI profile to the dedicated oracle-oci agent", () => {
|
||||
expect(agentDriverInstallKey("oracle", "oci")).toBe("oracle-oci");
|
||||
expect(showAgentDriverInstallHint("oracle", [], "oci")).toBe(true);
|
||||
expect(showAgentDriverInstallHint("oracle", [{ db_type: "oracle-oci", installed: true }], "oci")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps plain Oracle connections on the thin agent", () => {
|
||||
for (const profile of [undefined, "oracle"]) {
|
||||
expect(agentDriverInstallKey("oracle", profile)).toBe("oracle");
|
||||
expect(showAgentDriverInstallHint("oracle", [{ db_type: "oracle", installed: true }], profile)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldApplyDriverStoreFocus", () => {
|
||||
it("applies when the driver first appears after a list load", () => {
|
||||
expect(shouldApplyDriverStoreFocus(null, "driver:mysql", false)).toBe(true);
|
||||
|
||||
+48
-79
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AGENT_DRIVER_CATEGORY_MAP, DRIVER_CATEGORIES, assertAgentDriverCategoriesComplete, getCategoryForAgentDriver } from "@/lib/connection/driver-category-definitions";
|
||||
import { MANAGED_JDBC_DRIVERS } from "@/lib/database/managedJdbcDrivers";
|
||||
import { databaseManifestEntry, manifestDatabaseTypes } from "@/lib/database/databaseDriverManifest";
|
||||
|
||||
describe("getCategoryForAgentDriver", () => {
|
||||
it("returns correct category for one key from each of the 9 categories", () => {
|
||||
@@ -67,93 +69,60 @@ describe("AGENT_DRIVER_CATEGORY_MAP integrity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("covers all known agent driver keys with no stale entries", () => {
|
||||
// Golden set — mirrors the store-visible entries in agent_catalog.rs
|
||||
// plus EXTRA_DRIVER_STORE_ENTRIES (duckdb, kafka, rocketmq,
|
||||
// rabbitmq, sqlserver-legacy) and the built-in JDBC driver rows.
|
||||
// When an Agent driver is added to the catalog its key MUST appear here;
|
||||
// removing a key from the map without removing it from the list below
|
||||
// will fail this test.
|
||||
const expectedKeys = new Set([
|
||||
// sql
|
||||
"db2",
|
||||
"firebird",
|
||||
"informix",
|
||||
"iris",
|
||||
"oracle",
|
||||
"spanner",
|
||||
"sqlserver-legacy",
|
||||
// analytics
|
||||
"bigquery",
|
||||
"databend",
|
||||
"databricks",
|
||||
"exasol",
|
||||
"hive",
|
||||
"ignite",
|
||||
"ignite3",
|
||||
"kylin",
|
||||
"phoenix",
|
||||
"prestosql",
|
||||
"saphana",
|
||||
"snowflake",
|
||||
"spark",
|
||||
"teradata",
|
||||
"trino",
|
||||
"vertica",
|
||||
// domestic
|
||||
"dameng",
|
||||
"gbase8a",
|
||||
"gbase8s",
|
||||
"goldendb",
|
||||
"highgo",
|
||||
"kingbase",
|
||||
"oceanbase-oracle",
|
||||
"oscar",
|
||||
"sundb",
|
||||
"transwarp",
|
||||
"uxdb",
|
||||
"vastbase",
|
||||
"xugu",
|
||||
"yashandb",
|
||||
// lightweight
|
||||
"access",
|
||||
"duckdb",
|
||||
"h2",
|
||||
"sqlite-worker",
|
||||
// document
|
||||
"cassandra",
|
||||
"mongodb",
|
||||
// graph_ai
|
||||
"neo4j",
|
||||
"nebula",
|
||||
// timeseries
|
||||
"influxdb",
|
||||
"iotdb",
|
||||
"tdengine",
|
||||
// mq
|
||||
"kafka",
|
||||
"rabbitmq",
|
||||
"rocketmq",
|
||||
// registry_config
|
||||
"etcd",
|
||||
"zookeeper",
|
||||
]);
|
||||
const actualKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
|
||||
it("covers every driver the store can list, derived from the manifest", () => {
|
||||
// Derived, not hand-maintained: the driver store renders
|
||||
// `agent_catalog::driver_store_entries()` (the store-visible entries of
|
||||
// database-drivers.manifest.json) plus the built-in managed JDBC rows. A
|
||||
// hand-written list silently absorbs a missing key — `oracle-oci` shipped
|
||||
// store-visible with no category and only produced a console warning in dev.
|
||||
const expectedKeys = new Set<string>(manifestStoreVisibleAgentKeys());
|
||||
for (const definition of MANAGED_JDBC_DRIVERS) {
|
||||
expectedKeys.add(definition.id);
|
||||
}
|
||||
|
||||
expect(actualKeys).toHaveLength(expectedKeys.size);
|
||||
const actualKeys = new Set(Object.keys(AGENT_DRIVER_CATEGORY_MAP));
|
||||
const missing = [...expectedKeys].filter((key) => !actualKeys.has(key));
|
||||
|
||||
const actualSet = new Set(actualKeys);
|
||||
const missing = [...expectedKeys].filter((k) => !actualSet.has(k));
|
||||
const extra = actualKeys.filter((k) => !expectedKeys.has(k));
|
||||
|
||||
expect(missing, `Missing from AGENT_DRIVER_CATEGORY_MAP: ${missing.join(", ")}`).toEqual([]);
|
||||
expect(extra, `Extra keys in AGENT_DRIVER_CATEGORY_MAP not in expected set: ${extra.join(", ")}`).toEqual([]);
|
||||
expect(missing, `Store-visible drivers without a category: ${missing.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirrors `agent_catalog::driver_store_entries()`: the base agent key of every
|
||||
* store-visible entry, every store-visible profile (by `packageKey` when it
|
||||
* shares another module's package), and every store-visible managed driver.
|
||||
*/
|
||||
function manifestStoreVisibleAgentKeys(): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const dbType of manifestDatabaseTypes()) {
|
||||
const entry = databaseManifestEntry(dbType);
|
||||
if (!entry) continue;
|
||||
if (entry.driverStoreVisible && entry.agentKey) {
|
||||
keys.push(entry.agentKey);
|
||||
}
|
||||
for (const profile of entry.driverProfiles ?? []) {
|
||||
if (profile.storeVisible) {
|
||||
keys.push(profile.packageKey ?? profile.agentKey);
|
||||
}
|
||||
}
|
||||
for (const driver of entry.managedDrivers ?? []) {
|
||||
if (driver.storeVisible) {
|
||||
keys.push(driver.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
describe("built-in JDBC drivers match ConnectionDialog categories", () => {
|
||||
it("maps PrestoSQL and Apache Phoenix to analytics", () => {
|
||||
expect(getCategoryForAgentDriver("prestosql")).toBe("analytics");
|
||||
expect(getCategoryForAgentDriver("phoenix")).toBe("analytics");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Oracle OCI driver category", () => {
|
||||
it("groups the OCI (thick) agent with the thin driver under SQL", () => {
|
||||
expect(getCategoryForAgentDriver("oracle-oci")).toBe("sql");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,12 @@ export function agentDriverInstallKey(dbType: DatabaseType | undefined, driverPr
|
||||
if (dbType === "sqlite") return context?.ssh ? "sqlite-worker" : undefined;
|
||||
// argo owns its dedicated argo-go agent — only kyuubi/impala still share hive-go.
|
||||
if (dbType === "kyuubi" || dbType === "impala") return "hive";
|
||||
if (dbType === "oracle") return "oracle";
|
||||
// Oracle 的 OCI(thick)模式由独立的 oracle-oci agent 承担;其它 Oracle 连接继续用 thin agent。
|
||||
// agentKey 以连接类型声明(connection-types/oracle.yaml)为准,避免两处各写一份映射。
|
||||
if (dbType === "oracle") {
|
||||
const entry = databaseManifestEntry(dbType);
|
||||
return entry?.driverProfiles?.find((profile) => profile.profile === driverProfile)?.agentKey ?? entry?.agentKey;
|
||||
}
|
||||
if (dbType === "h2") return "h2";
|
||||
if (dbType === "transwarp") return "transwarp";
|
||||
if (dbType === "nebula") {
|
||||
|
||||
@@ -18,7 +18,9 @@ const EMPTY = 0;
|
||||
|
||||
export const AGENT_DRIVER_CATEGORY_MAP: Readonly<Record<string, DriverCategoryKey>> = {
|
||||
access: "lightweight",
|
||||
argo: "domestic",
|
||||
bigquery: "analytics",
|
||||
cache: "sql",
|
||||
cassandra: "document",
|
||||
dameng: "domestic",
|
||||
databend: "analytics",
|
||||
@@ -27,6 +29,7 @@ export const AGENT_DRIVER_CATEGORY_MAP: Readonly<Record<string, DriverCategoryKe
|
||||
duckdb: "lightweight",
|
||||
"sqlite-worker": "lightweight",
|
||||
etcd: "registry_config",
|
||||
etcd2: "registry_config",
|
||||
exasol: "analytics",
|
||||
firebird: "sql",
|
||||
gbase8a: "domestic",
|
||||
@@ -49,6 +52,9 @@ export const AGENT_DRIVER_CATEGORY_MAP: Readonly<Record<string, DriverCategoryKe
|
||||
nebula: "graph_ai",
|
||||
"oceanbase-oracle": "domestic",
|
||||
oracle: "sql",
|
||||
// OCI (thick) mode ships as its own store-visible agent package under the
|
||||
// Oracle connection type, so it needs its own category entry.
|
||||
"oracle-oci": "sql",
|
||||
oscar: "domestic",
|
||||
phoenix: "analytics",
|
||||
prestosql: "analytics",
|
||||
|
||||
@@ -43,7 +43,9 @@ describe("WebView startup sources stay compatible with old WebKit regex syntax",
|
||||
const source = readFileSync(file, "utf8");
|
||||
return source.includes("(?<=") || source.includes("(?<!");
|
||||
})
|
||||
.map((file) => path.relative(workspaceRoot, file))
|
||||
// 规范化路径分隔符:#allowlist 用的是仓库相对路径的 POSIX 形式,
|
||||
// 否则在 Windows 上 path.relative 返回反斜杠会误报(白名单文件被当成违规)。
|
||||
.map((file) => path.relative(workspaceRoot, file).split(path.sep).join("/"))
|
||||
.filter((relative) => !LOOKBEHIND_ALLOWLIST.has(relative));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -911,6 +911,27 @@ export interface EditorSettings {
|
||||
showColumnHeaderTooltips: boolean;
|
||||
/** 结果集页签/结果列表的名称是否带上库名(关闭后只显示表名,完整名称仍在悬浮提示中)。 */
|
||||
showResultSourceDatabase: boolean;
|
||||
/**
|
||||
* Global Oracle Instant Client location (the directory that contains
|
||||
* oci.dll / libclnts). Shared by every Oracle connection whose driver mode
|
||||
* is OCI; the connection dialog backfills it and edits write it back here.
|
||||
*/
|
||||
oracleOciClientPath: string;
|
||||
/**
|
||||
* Client character set for OCI connections, injected as the NLS_LANG
|
||||
* environment variable when the OCI agent starts. Empty keeps the OCI
|
||||
* default; Chinese environments usually want AL32UTF8 (matching the
|
||||
* database charset) to avoid mojibake.
|
||||
*/
|
||||
oracleOciNlsLang: string;
|
||||
/**
|
||||
* Global TNS_ADMIN directory for OCI connections (tnsnames.ora / sqlnet.ora
|
||||
* / wallet), injected as the TNS_ADMIN environment variable when the OCI
|
||||
* agent starts. A connection-level override wins over this default; this is
|
||||
* what makes ADB wallets and sqlnet.ora network options work without
|
||||
* switching the connection to the TNS form.
|
||||
*/
|
||||
oracleOciTnsAdmin: string;
|
||||
/** Naming strategy for query result execution and result-set tabs. */
|
||||
resultTabNamingMode: ResultTabNamingMode;
|
||||
/** Prefer SQL preamble comments in source naming mode, preserving the original naming behavior. */
|
||||
@@ -1205,6 +1226,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
showColumnTypesInHeader: true,
|
||||
showColumnHeaderTooltips: true,
|
||||
showResultSourceDatabase: true,
|
||||
oracleOciClientPath: "",
|
||||
oracleOciNlsLang: "",
|
||||
oracleOciTnsAdmin: "",
|
||||
resultTabNamingMode: "source",
|
||||
resultTabPreferComments: true,
|
||||
dataGridShowTransposeFieldMetadata: false,
|
||||
@@ -1785,6 +1809,9 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
showColumnTypesInHeader: settings.showColumnTypesInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader,
|
||||
showColumnHeaderTooltips: settings.showColumnHeaderTooltips ?? DEFAULT_EDITOR_SETTINGS.showColumnHeaderTooltips,
|
||||
showResultSourceDatabase: settings.showResultSourceDatabase ?? DEFAULT_EDITOR_SETTINGS.showResultSourceDatabase,
|
||||
oracleOciNlsLang: typeof settings.oracleOciNlsLang === "string" ? settings.oracleOciNlsLang.trim() : "",
|
||||
oracleOciTnsAdmin: typeof settings.oracleOciTnsAdmin === "string" ? settings.oracleOciTnsAdmin.trim() : "",
|
||||
oracleOciClientPath: typeof settings.oracleOciClientPath === "string" ? settings.oracleOciClientPath.trim() : "",
|
||||
resultTabNamingMode: normalizeResultTabNamingMode(settings.resultTabNamingMode),
|
||||
resultTabPreferComments: settings.resultTabPreferComments !== false,
|
||||
dataGridShowTransposeFieldMetadata: settings.dataGridShowTransposeFieldMetadata === true,
|
||||
@@ -2628,6 +2655,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.showColumnTypesInHeader !== undefined) editorSettings.value.showColumnTypesInHeader = partial.showColumnTypesInHeader;
|
||||
if (partial.showColumnHeaderTooltips !== undefined) editorSettings.value.showColumnHeaderTooltips = partial.showColumnHeaderTooltips;
|
||||
if (partial.showResultSourceDatabase !== undefined) editorSettings.value.showResultSourceDatabase = partial.showResultSourceDatabase;
|
||||
if (partial.oracleOciNlsLang !== undefined) editorSettings.value.oracleOciNlsLang = partial.oracleOciNlsLang.trim();
|
||||
if (partial.oracleOciTnsAdmin !== undefined) editorSettings.value.oracleOciTnsAdmin = partial.oracleOciTnsAdmin.trim();
|
||||
if (partial.oracleOciClientPath !== undefined) editorSettings.value.oracleOciClientPath = partial.oracleOciClientPath.trim();
|
||||
if (partial.resultTabNamingMode !== undefined) editorSettings.value.resultTabNamingMode = normalizeResultTabNamingMode(partial.resultTabNamingMode);
|
||||
if (partial.resultTabPreferComments !== undefined) editorSettings.value.resultTabPreferComments = partial.resultTabPreferComments !== false;
|
||||
if (partial.dataGridShowTransposeFieldMetadata !== undefined) editorSettings.value.dataGridShowTransposeFieldMetadata = partial.dataGridShowTransposeFieldMetadata === true;
|
||||
|
||||
@@ -129,6 +129,13 @@ export interface ConnectionConfig {
|
||||
client_key_path?: string;
|
||||
sysdba?: boolean;
|
||||
oracle_connection_type?: "service_name" | "sid" | "tns";
|
||||
/** Connection-level NLS_LANG override; empty follows the global default. */
|
||||
oracle_oci_nls_lang?: string;
|
||||
/**
|
||||
* Connection-level TNS_ADMIN override for OCI (tnsnames.ora / sqlnet.ora /
|
||||
* wallet directory); empty follows the global default.
|
||||
*/
|
||||
oracle_oci_tns_admin?: string;
|
||||
connection_string?: string;
|
||||
jdbc_driver_class?: string;
|
||||
jdbc_driver_paths?: string[];
|
||||
|
||||
@@ -447,6 +447,13 @@
|
||||
"agentKey": "oracle",
|
||||
"label": "Oracle",
|
||||
"storeVisible": false
|
||||
},
|
||||
{
|
||||
"profile": "oci",
|
||||
"agentKey": "oracle-oci",
|
||||
"label": "Oracle (OCI)",
|
||||
"storeVisible": true,
|
||||
"storeOrder": 54
|
||||
}
|
||||
],
|
||||
"singleConnectionPool": true,
|
||||
|
||||
@@ -151,6 +151,8 @@ fn env_required(name: &str) -> Result<String, String> {
|
||||
fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConfig, String> {
|
||||
let database_name = env_required("DBX_BENCH_DATABASE")?;
|
||||
Ok(ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -179,6 +179,8 @@ mod tests {
|
||||
|
||||
fn connection_with_external(value: serde_json::Value) -> ConnectionConfig {
|
||||
let mut cfg = ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "c1".to_string(),
|
||||
name: "mq".to_string(),
|
||||
|
||||
@@ -921,6 +921,8 @@ mod tests {
|
||||
|
||||
fn mq_connection(read_only: bool) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "readonly-mq".to_string(),
|
||||
name: "Read only MQ".to_string(),
|
||||
|
||||
@@ -366,6 +366,8 @@ mod tests {
|
||||
|
||||
fn connection_with_external(value: serde_json::Value) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "nacos-1".to_string(),
|
||||
name: "Nacos".to_string(),
|
||||
|
||||
@@ -642,6 +642,8 @@ mod tests {
|
||||
let storage = crate::persistence::test_storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new(storage);
|
||||
let mut cfg = crate::models::connection::ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "nacos-1".to_string(),
|
||||
name: "Nacos".to_string(),
|
||||
@@ -725,6 +727,8 @@ mod tests {
|
||||
let storage = crate::persistence::test_storage::open(&dir.join("storage.db")).await.unwrap();
|
||||
let state = AppState::new(storage);
|
||||
let cfg = crate::models::connection::ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "nacos-rollback".to_string(),
|
||||
name: "Nacos".to_string(),
|
||||
|
||||
@@ -1977,6 +1977,8 @@ for line in sys.stdin:
|
||||
#[cfg(unix)]
|
||||
fn agent_test_connection(id: &str, name: &str, db_type: DatabaseType, database: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
|
||||
@@ -1030,6 +1030,8 @@ mod tests {
|
||||
|
||||
fn connection(id: &str, password: &str, _ssh_password: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: format!("{id} connection"),
|
||||
|
||||
@@ -1361,15 +1361,15 @@ mod tests {
|
||||
assert!(plugins_root.join("jdbc/manifest.json").exists());
|
||||
let bundles = list_jdbc_maven_bundles(&plugins_root).unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
// 比较 Path 而不是字符串:生产代码在 Windows 上用反斜杠拼接,而被期望值里
|
||||
// 内嵌了 `/`,直接比字符串会在 Windows 上误报。
|
||||
assert_eq!(
|
||||
bundles[0].path,
|
||||
plugins_root.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0").to_string_lossy()
|
||||
std::path::PathBuf::from(&bundles[0].path),
|
||||
plugins_root.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
bundles[0].artifacts[0].path,
|
||||
plugins_root
|
||||
.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0/jars/demo-driver-1.0.0.jar")
|
||||
.to_string_lossy()
|
||||
std::path::PathBuf::from(&bundles[0].artifacts[0].path),
|
||||
plugins_root.join("jdbc/drivers/maven/com.example_demo-driver_1.0.0/jars/demo-driver-1.0.0.jar")
|
||||
);
|
||||
assert_eq!(std::fs::read(&bundles[0].artifacts[0].path).unwrap(), b"offline-driver");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
|
||||
@@ -1319,6 +1319,25 @@ fn mysql_metadata_fallback_url(
|
||||
Some(connection_url_for_endpoint(config, host, port))
|
||||
}
|
||||
|
||||
/// Global Oracle OCI defaults pulled from editor settings.
|
||||
#[derive(Default)]
|
||||
struct OracleOciDefaults {
|
||||
nls_lang: Option<String>,
|
||||
client_path: Option<String>,
|
||||
tns_admin: Option<String>,
|
||||
}
|
||||
|
||||
/// Trims an optional setting, treating whitespace-only input as unset.
|
||||
fn oracle_oci_trimmed(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Reads a trimmed non-empty string out of the editor settings blob.
|
||||
fn json_trimmed_string(settings: &serde_json::Value, key: &str) -> Option<String> {
|
||||
let text = settings.get(key)?.as_str()?.trim();
|
||||
(!text.is_empty()).then(|| text.to_string())
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn shared_resource_budget(&self, name: &str, capacity: usize) -> Result<Arc<Semaphore>, String> {
|
||||
if capacity == 0 {
|
||||
@@ -1556,11 +1575,55 @@ impl AppState {
|
||||
error.message
|
||||
}
|
||||
|
||||
/// Resolves the agent-process environment a connection needs.
|
||||
///
|
||||
/// Only Oracle OCI ("thick") connections declare one today. The Oracle
|
||||
/// Client is a process-scoped C library: `NLS_LANG` is read once when the
|
||||
/// client initialises and the Instant Client is located through the platform
|
||||
/// loader path, so neither can travel as a connection parameter — they have
|
||||
/// to be baked into the agent process. Because the environment is part of
|
||||
/// the launch fingerprint, a non-empty result also gives the connection its
|
||||
/// own process instead of sharing one with other Oracle connections.
|
||||
pub async fn agent_launch_env(&self, config: &ConnectionConfig) -> Vec<(String, String)> {
|
||||
if config.driver_profile.as_deref() != Some(crate::oracle_oci::ORACLE_OCI_DRIVER_PROFILE) {
|
||||
return Vec::new();
|
||||
}
|
||||
let defaults = self.oracle_oci_defaults().await;
|
||||
let nls_lang = oracle_oci_trimmed(config.oracle_oci_nls_lang.as_deref())
|
||||
.or_else(|| oracle_oci_trimmed(defaults.nls_lang.as_deref()));
|
||||
// TNS_ADMIN 的解析顺序:连接级字段 > TNS 连接串里打包的目录 > 全局默认。
|
||||
// 钱包(ADB)与 sqlnet.ora 都依赖这个目录,任何 OCI 连接都可以不经过
|
||||
// TNS 连接方式直接使用它们;目录同样无法在连接建立后更改。
|
||||
let tns_admin = crate::oracle_oci::resolve_oci_tns_admin(
|
||||
config.oracle_oci_tns_admin.as_deref(),
|
||||
config.connection_string.as_deref(),
|
||||
defaults.tns_admin.as_deref(),
|
||||
);
|
||||
crate::oracle_oci::oracle_oci_launch_env(nls_lang, defaults.client_path.as_deref(), tns_admin.as_deref())
|
||||
}
|
||||
|
||||
/// Reads the global Oracle OCI defaults from editor settings.
|
||||
///
|
||||
/// Editor settings are one opaque JSON blob owned by the frontend, so a
|
||||
/// missing or malformed value degrades to "unset" rather than failing the
|
||||
/// connection.
|
||||
async fn oracle_oci_defaults(&self) -> OracleOciDefaults {
|
||||
let Ok(Some(settings)) = self.storage.load_editor_settings().await else {
|
||||
return OracleOciDefaults::default();
|
||||
};
|
||||
OracleOciDefaults {
|
||||
nls_lang: json_trimmed_string(&settings, "oracleOciNlsLang"),
|
||||
client_path: json_trimmed_string(&settings, "oracleOciClientPath"),
|
||||
tns_admin: json_trimmed_string(&settings, "oracleOciTnsAdmin"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_routed_shared_agent_client(
|
||||
&self,
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
extra_java_args: &[String],
|
||||
agent_env: &[(String, String)],
|
||||
agent_session_id: String,
|
||||
connect_params: serde_json::Value,
|
||||
connect_timeout: Duration,
|
||||
@@ -1571,6 +1634,7 @@ impl AppState {
|
||||
db_type,
|
||||
driver_profile,
|
||||
extra_java_args,
|
||||
agent_env,
|
||||
agent_session_id,
|
||||
connect_params,
|
||||
connect_timeout,
|
||||
@@ -3018,11 +3082,13 @@ impl AppState {
|
||||
)?;
|
||||
if db_config.db_type != DatabaseType::ZooKeeper {
|
||||
let agent_session_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let agent_env = self.agent_launch_env(&db_config).await;
|
||||
let mut initial_result = self
|
||||
.spawn_routed_shared_agent_client(
|
||||
&db_config.db_type,
|
||||
db_config.driver_profile.as_deref(),
|
||||
&db_config.agent_java_options,
|
||||
&agent_env,
|
||||
agent_session_id.clone(),
|
||||
connect_params,
|
||||
agent_connect_timeout(&db_config),
|
||||
@@ -3043,6 +3109,7 @@ impl AppState {
|
||||
&db_config.db_type,
|
||||
db_config.driver_profile.as_deref(),
|
||||
&db_config.agent_java_options,
|
||||
&agent_env,
|
||||
agent_session_id.clone(),
|
||||
agent_connect_params_with_role(
|
||||
&db_config,
|
||||
@@ -3106,11 +3173,13 @@ impl AppState {
|
||||
alternate_config.effective_database().unwrap_or(""),
|
||||
session_role,
|
||||
)?;
|
||||
let alternate_env = self.agent_launch_env(&alternate_config).await;
|
||||
match self
|
||||
.spawn_routed_shared_agent_client(
|
||||
&alternate_config.db_type,
|
||||
alternate_config.driver_profile.as_deref(),
|
||||
&alternate_config.agent_java_options,
|
||||
&alternate_env,
|
||||
agent_session_id.clone(),
|
||||
alternate_params,
|
||||
agent_connect_timeout(&alternate_config),
|
||||
@@ -7051,6 +7120,8 @@ mod tests {
|
||||
|
||||
fn mysql_config(database: Option<&str>) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "conn".to_string(),
|
||||
name: "MySQL".to_string(),
|
||||
@@ -9553,6 +9624,8 @@ sleep 30
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
connection_string: None,
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
|
||||
@@ -11868,6 +11868,8 @@ for line in sys.stdin:
|
||||
|
||||
fn jdbc_transfer_config(connection_string: &str, driver_class: &str, profile: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
id: "test-jdbc".to_string(),
|
||||
name: "Test JDBC".to_string(),
|
||||
note: String::new(),
|
||||
@@ -18140,6 +18142,8 @@ SELECT 1 FROM dual"#
|
||||
#[test]
|
||||
fn resolve_external_transfer_catalog_for_config_accepts_starrocks_driver_profile() {
|
||||
let config = crate::models::connection::ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "sr".to_string(),
|
||||
name: "sr".to_string(),
|
||||
|
||||
@@ -27,6 +27,7 @@ pub use dbx_drivers::agent_recovery;
|
||||
pub use dbx_drivers::agent_runtime;
|
||||
pub use dbx_drivers::agent_service;
|
||||
pub use dbx_drivers::backend_error;
|
||||
pub use dbx_drivers::oracle_oci;
|
||||
pub use host::changelog;
|
||||
pub use persistence::cloud_sync;
|
||||
pub use persistence::config;
|
||||
|
||||
@@ -3440,6 +3440,8 @@ mod tests {
|
||||
|
||||
fn postgres_connection(id: &str, password: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: "Postgres".to_string(),
|
||||
@@ -3524,6 +3526,8 @@ mod tests {
|
||||
|
||||
fn nacos_connection(id: &str, password: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: "Nacos".to_string(),
|
||||
@@ -3764,6 +3768,8 @@ mod tests {
|
||||
#[test]
|
||||
fn scrubs_connection_secret_fields() {
|
||||
let mut config = ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "id".to_string(),
|
||||
name: "name".to_string(),
|
||||
|
||||
@@ -10773,6 +10773,8 @@ mod tests {
|
||||
|
||||
fn mq_connection(id: &str, token: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: "Pulsar".to_string(),
|
||||
@@ -10848,6 +10850,8 @@ mod tests {
|
||||
|
||||
fn nacos_connection(id: &str, password: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: "Nacos".to_string(),
|
||||
@@ -11071,7 +11075,10 @@ mod tests {
|
||||
let target_dir = temp_data_dir("import-empty-target");
|
||||
std::fs::create_dir_all(managed_key_path(&target_dir).parent().unwrap()).unwrap();
|
||||
std::fs::copy(managed_key_path(&source_dir), managed_key_path(&target_dir)).unwrap();
|
||||
let _target_storage = crate::persistence::test_storage::open(&target_dir.join("dbx.db")).await.unwrap();
|
||||
// 打开一次以创建空目标库;导入前必须释放,否则 Windows 上目标文件被占用、
|
||||
// 替换会失败(与相邻用例一致)。
|
||||
let target_storage = crate::persistence::test_storage::open(&target_dir.join("dbx.db")).await.unwrap();
|
||||
drop(target_storage);
|
||||
|
||||
let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap();
|
||||
|
||||
|
||||
@@ -8047,6 +8047,8 @@ for line in sys.stdin:
|
||||
|
||||
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "conn-1".to_string(),
|
||||
name: "Connection".to_string(),
|
||||
@@ -10889,6 +10891,8 @@ for line in sys.stdin:
|
||||
#[test]
|
||||
fn external_driver_query_params_include_database_and_schema_context() {
|
||||
let config = ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "jdbc-1".to_string(),
|
||||
name: "JDBC".to_string(),
|
||||
|
||||
@@ -758,6 +758,8 @@ mod tests {
|
||||
|
||||
fn config() -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "conn".to_string(),
|
||||
name: "test".to_string(),
|
||||
|
||||
@@ -3291,6 +3291,8 @@ mod tests {
|
||||
|
||||
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "test".to_string(),
|
||||
name: "test".to_string(),
|
||||
|
||||
@@ -145,6 +145,8 @@ mod tests {
|
||||
|
||||
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
id: "test".to_string(),
|
||||
name: "test".to_string(),
|
||||
db_type,
|
||||
|
||||
@@ -175,6 +175,104 @@ fn registry_with_native_driver(db_type: &str, version: &str, jre: &str) -> Agent
|
||||
AgentRegistry { jre: None, jres: std::collections::HashMap::new(), drivers }
|
||||
}
|
||||
|
||||
/// A native-only driver the registry publishes for every platform *except* this
|
||||
/// one, mirroring the Windows-only Oracle OCI agent on macOS and Linux.
|
||||
fn registry_with_foreign_platform_native_driver(db_type: &str, version: &str) -> AgentRegistry {
|
||||
let current = AgentManager::current_platform();
|
||||
let native = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"]
|
||||
.into_iter()
|
||||
.filter(|platform| *platform != current)
|
||||
.map(|platform| {
|
||||
(
|
||||
platform.to_string(),
|
||||
ArtifactInfo {
|
||||
url: format!("https://example.com/dbx-agent-{db_type}-{platform}"),
|
||||
sha256: None,
|
||||
size: 42,
|
||||
format: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut drivers = std::collections::HashMap::new();
|
||||
drivers.insert(
|
||||
db_type.to_string(),
|
||||
DriverInfo {
|
||||
version: version.to_string(),
|
||||
label: db_type.to_string(),
|
||||
min_app_version: "0.1.0".to_string(),
|
||||
jre: DEFAULT_JRE_KEY.to_string(),
|
||||
// Native-only drivers still carry a zero-size legacy JAR placeholder,
|
||||
// which must not be mistaken for an installable fallback.
|
||||
jar: Some(ArtifactInfo {
|
||||
url: format!("https://example.com/dbx-agent-{db_type}-legacy-placeholder.jar"),
|
||||
sha256: None,
|
||||
size: 0,
|
||||
format: None,
|
||||
}),
|
||||
native,
|
||||
},
|
||||
);
|
||||
AgentRegistry { jre: None, jres: std::collections::HashMap::new(), drivers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_hides_a_driver_the_registry_publishes_for_other_platforms_only() {
|
||||
let manager = test_manager("foreign-platform-hidden");
|
||||
let registry = registry_with_foreign_platform_native_driver("oracle-oci", "0.1.0");
|
||||
|
||||
let agents = build_agent_list(&manager, Some(®istry));
|
||||
|
||||
assert!(
|
||||
!agents.iter().any(|agent| agent.db_type == "oracle-oci"),
|
||||
"a driver with no artifact for this platform must not be offered for install"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_keeps_an_installed_driver_the_registry_publishes_for_other_platforms_only() {
|
||||
let manager = test_manager("foreign-platform-installed");
|
||||
let native_path = manager.driver_native_path("oracle-oci");
|
||||
std::fs::create_dir_all(native_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&native_path, b"agent").unwrap();
|
||||
manager
|
||||
.save_state(&dbx_core::agent_manager::AgentState {
|
||||
installed_drivers: [(
|
||||
"oracle-oci".to_string(),
|
||||
InstalledDriver {
|
||||
version: "0.1.0".to_string(),
|
||||
installed_at: "2026-05-18T00:00:00Z".to_string(),
|
||||
jre: DEFAULT_JRE_KEY.to_string(),
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let registry = registry_with_foreign_platform_native_driver("oracle-oci", "0.1.0");
|
||||
|
||||
let agents = build_agent_list(&manager, Some(®istry));
|
||||
|
||||
let oci = agents
|
||||
.iter()
|
||||
.find(|agent| agent.db_type == "oracle-oci")
|
||||
.expect("an installed driver must stay listed so it can be upgraded or removed");
|
||||
assert!(oci.installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_keeps_the_sqlite_worker_although_its_binaries_target_another_platform() {
|
||||
// The SSH worker executes on the remote host, so a `native` set without a
|
||||
// desktop platform must not hide it (#8987).
|
||||
let manager = test_manager("sqlite-worker-platform-gate");
|
||||
let registry = registry_with_foreign_platform_native_driver("sqlite-worker", "0.1.0");
|
||||
|
||||
let agents = build_agent_list(&manager, Some(®istry));
|
||||
|
||||
assert!(agents.iter().any(|agent| agent.db_type == "sqlite-worker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_agent_list_includes_expected_driver_labels() {
|
||||
let manager = test_manager("labels");
|
||||
|
||||
@@ -6,6 +6,8 @@ use std::str::FromStr;
|
||||
|
||||
fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "id".to_string(),
|
||||
name: "name".to_string(),
|
||||
|
||||
@@ -21,6 +21,8 @@ fn live_postgres_config(
|
||||
database: &str,
|
||||
) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -17,6 +17,8 @@ fn live_postgres_config(
|
||||
database: &str,
|
||||
) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -12,6 +12,8 @@ fn live_postgres_config(
|
||||
database: &str,
|
||||
) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -166,6 +166,8 @@ fn live_connection_config(
|
||||
database: &str,
|
||||
) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -17,6 +17,8 @@ fn live_postgres_config(
|
||||
database: &str,
|
||||
) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::sync::Arc;
|
||||
|
||||
fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -16,6 +16,8 @@ use std::time::Duration;
|
||||
|
||||
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -18,6 +18,8 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connection::ConnectionConfig {
|
||||
dbx_core::models::connection::ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connection::ConnectionConfig {
|
||||
dbx_core::models::connection::ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -22,6 +22,8 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connection::ConnectionConfig {
|
||||
dbx_core::models::connection::ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::time::Duration;
|
||||
|
||||
fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -132,6 +132,8 @@ pub fn psql_allow_failure(container: &DockerPostgres, sql: &str) -> bool {
|
||||
|
||||
pub fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
|
||||
@@ -415,6 +415,9 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
|
||||
_ => vec![sid_url, legacy_service_url, descriptor_service_url, descriptor_sid_url],
|
||||
}
|
||||
};
|
||||
// The retries travel through `oracle_jdbc_connection_string` as a stored
|
||||
// connection string, so they must already carry the profile's protocol.
|
||||
let candidates: Vec<String> = candidates.into_iter().map(|url| oracle_url_for_profile(config, url)).collect();
|
||||
|
||||
let mut urls = Vec::new();
|
||||
for url in candidates {
|
||||
@@ -434,6 +437,20 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Applies the OCI (thick) protocol to an Oracle JDBC URL when the connection
|
||||
/// selects the OCI driver profile. Thin connections keep the URL unchanged.
|
||||
///
|
||||
/// OCI reaches the same targets as thin; the TNS form additionally moves its
|
||||
/// `TNS_ADMIN` query into the agent-process environment
|
||||
/// (`crate::oracle_oci::oracle_oci_launch_env`).
|
||||
fn oracle_url_for_profile(config: &ConnectionConfig, url: String) -> String {
|
||||
if !crate::oracle_oci::uses_oracle_oci_profile(config) {
|
||||
return url;
|
||||
}
|
||||
let rewritten = crate::oracle_oci::rewrite_oracle_url_protocol(&url, true);
|
||||
crate::oracle_oci::strip_oracle_tns_admin_query(&rewritten)
|
||||
}
|
||||
|
||||
fn oracle_jdbc_connection_string(
|
||||
config: &ConnectionConfig,
|
||||
host: &str,
|
||||
@@ -443,9 +460,12 @@ fn oracle_jdbc_connection_string(
|
||||
if let Some(connection_string) = config.connection_string.as_deref().filter(|value| !value.trim().is_empty()) {
|
||||
let connection_string = connection_string.trim();
|
||||
if host == config.host && port == config.port {
|
||||
return Ok(connection_string.to_string());
|
||||
return Ok(oracle_url_for_profile(config, connection_string.to_string()));
|
||||
}
|
||||
return crate::models::connection::rewrite_jdbc_url_host(connection_string, host, port);
|
||||
return Ok(oracle_url_for_profile(
|
||||
config,
|
||||
crate::models::connection::rewrite_jdbc_url_host(connection_string, host, port)?,
|
||||
));
|
||||
}
|
||||
|
||||
let database = database.trim();
|
||||
@@ -453,11 +473,12 @@ fn oracle_jdbc_connection_string(
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
Ok(if config.oracle_connection_type.as_deref() == Some("sid") {
|
||||
let url = if config.oracle_connection_type.as_deref() == Some("sid") {
|
||||
oracle_sid_jdbc_url(host, port, database)
|
||||
} else {
|
||||
oracle_service_jdbc_url(host, port, database)
|
||||
})
|
||||
};
|
||||
Ok(oracle_url_for_profile(config, url))
|
||||
}
|
||||
|
||||
fn oracle_listener_error_can_retry(err: &str) -> bool {
|
||||
@@ -764,6 +785,8 @@ mod tests {
|
||||
|
||||
fn config(db_type: DatabaseType, database: Option<&str>) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "conn".to_string(),
|
||||
name: "Connection".to_string(),
|
||||
@@ -888,6 +911,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_oci_profile_builds_oci8_urls_for_service_and_sid() {
|
||||
for (mode, expected_url) in [
|
||||
("service_name", "jdbc:oracle:oci8:@//oracle.example.com:1521/ORCLPDB1"),
|
||||
("sid", "jdbc:oracle:oci8:@oracle.example.com:1521:ORCLPDB1"),
|
||||
] {
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));
|
||||
cfg.driver_profile = Some(crate::oracle_oci::ORACLE_OCI_DRIVER_PROFILE.to_string());
|
||||
cfg.oracle_connection_type = Some(mode.to_string());
|
||||
|
||||
let params = agent_connect_params(&cfg, "oracle.example.com", 1521, "ORCLPDB1").unwrap();
|
||||
|
||||
assert_eq!(params["connection_string"], expected_url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_oci_profile_rewrites_tns_urls_and_strips_the_admin_query() {
|
||||
let mut cfg = config(DatabaseType::Oracle, None);
|
||||
cfg.driver_profile = Some(crate::oracle_oci::ORACLE_OCI_DRIVER_PROFILE.to_string());
|
||||
cfg.connection_string = Some("jdbc:oracle:oci8:@ORCLPDB1?TNS_ADMIN=C%3A%5Coracle%5Cwallets".to_string());
|
||||
|
||||
// 主机与端口沿用连接自身:TNS 别名 URL 不参与主机改写(别名没有 host)。
|
||||
let params = agent_connect_params(&cfg, &cfg.host.clone(), cfg.port, "").unwrap();
|
||||
|
||||
assert_eq!(params["connection_string"], "jdbc:oracle:oci8:@ORCLPDB1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_thin_profile_keeps_the_thin_protocol() {
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));
|
||||
cfg.oracle_connection_type = Some("service_name".to_string());
|
||||
|
||||
let params = agent_connect_params(&cfg, "oracle.example.com", 1521, "ORCLPDB1").unwrap();
|
||||
|
||||
assert_eq!(params["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCLPDB1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_oci_alternate_connect_configs_stay_on_the_oci_protocol() {
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));
|
||||
cfg.driver_profile = Some(crate::oracle_oci::ORACLE_OCI_DRIVER_PROFILE.to_string());
|
||||
cfg.oracle_connection_type = Some("service_name".to_string());
|
||||
|
||||
let alternates =
|
||||
oracle_alternate_connect_configs(&cfg, "ORA-12514: listener does not currently know of service");
|
||||
|
||||
assert!(!alternates.is_empty());
|
||||
for alternate in &alternates {
|
||||
let url = alternate.connection_string.as_deref().expect("alternate url");
|
||||
assert!(url.starts_with("jdbc:oracle:oci8:@"), "alternate url must keep the oci8 protocol: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_hive_uses_current_url_params_for_zookeeper_detection() {
|
||||
let mut cfg = config(DatabaseType::Hive, Some("default"));
|
||||
|
||||
@@ -962,16 +962,24 @@ impl PooledAgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AgentLaunchSpec {
|
||||
pub program: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub working_dir: Option<PathBuf>,
|
||||
/// Extra environment variables handed to the agent process.
|
||||
///
|
||||
/// This is part of the launch *identity*, not just the launch mechanics:
|
||||
/// [`crate::agent_runtime::shared_runtime_key`] hashes it, so two launches
|
||||
/// that differ only in `env` get separate agent processes. That is what
|
||||
/// makes process-scoped Oracle Client settings (`NLS_LANG`) safe to set per
|
||||
/// connection instead of leaking across connections that share a process.
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AgentLaunchSpec {
|
||||
pub fn new(program: impl Into<PathBuf>) -> Self {
|
||||
Self { program: program.into(), args: Vec::new(), working_dir: None }
|
||||
Self { program: program.into(), args: Vec::new(), working_dir: None, env: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn java_jar(java_path: impl Into<PathBuf>, jar_path: impl AsRef<Path>) -> Self {
|
||||
@@ -988,6 +996,7 @@ impl AgentLaunchSpec {
|
||||
program: java_path.into(),
|
||||
args: agent_java_args_with_extra_args(&jar_path.to_string_lossy(), extra_java_args),
|
||||
working_dir: jar_path.parent().map(Path::to_path_buf),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,6 +1009,15 @@ impl AgentLaunchSpec {
|
||||
self.working_dir = Some(working_dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Appends `env` after any entries already present.
|
||||
///
|
||||
/// Later entries win when [`Self::env`] is applied to the child process,
|
||||
/// so callers can layer connection-level overrides on top of defaults.
|
||||
pub fn with_env(mut self, env: impl IntoIterator<Item = (String, String)>) -> Self {
|
||||
self.env.extend(env);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -3320,6 +3338,9 @@ fn agent_command(launch: &AgentLaunchSpec) -> Command {
|
||||
command.current_dir(working_dir);
|
||||
}
|
||||
remove_agent_proxy_env(&mut command);
|
||||
// Applied after the proxy scrub so an explicitly configured variable (for
|
||||
// example an Oracle Client path) is never silently dropped.
|
||||
command.envs(launch.env.iter().map(|(key, value)| (key.as_str(), value.as_str())));
|
||||
command
|
||||
}
|
||||
|
||||
|
||||
@@ -607,10 +607,11 @@ mod tests {
|
||||
.expect("manifest launch should resolve");
|
||||
|
||||
assert_eq!(launch.program, driver_dir.join("bin").join("dameng-agent"));
|
||||
assert_eq!(
|
||||
launch.args,
|
||||
vec!["--config".to_string(), driver_dir.join("config.json").to_string_lossy().to_string()]
|
||||
);
|
||||
// 比较 Path:模板里写的是正斜杠,而 Path::join 在 Windows 上产出反斜杠,
|
||||
// 直接比字符串会在 Windows 上误报。
|
||||
assert_eq!(launch.args.len(), 2);
|
||||
assert_eq!(launch.args[0], "--config");
|
||||
assert_eq!(std::path::PathBuf::from(&launch.args[1]), driver_dir.join("config.json"));
|
||||
assert_eq!(launch.working_dir.as_deref(), Some(driver_dir.as_path()));
|
||||
}
|
||||
|
||||
@@ -1122,6 +1123,22 @@ impl AgentManager {
|
||||
driver_key: &str,
|
||||
jre_key: &str,
|
||||
extra_java_args: &[String],
|
||||
) -> Result<AgentLaunchSpec, String> {
|
||||
self.resolve_agent_launch_spec_with_launch_env(state, driver_key, jre_key, extra_java_args, &[])
|
||||
}
|
||||
|
||||
/// Same as [`Self::resolve_agent_launch_spec_with_extra_args`] but also
|
||||
/// attaches `env` to the resolved spec.
|
||||
///
|
||||
/// The entries become part of the launch fingerprint, so two connections
|
||||
/// requesting different environments never share an agent process.
|
||||
pub fn resolve_agent_launch_spec_with_launch_env(
|
||||
&self,
|
||||
state: &AgentState,
|
||||
driver_key: &str,
|
||||
jre_key: &str,
|
||||
extra_java_args: &[String],
|
||||
env: &[(String, String)],
|
||||
) -> Result<AgentLaunchSpec, String> {
|
||||
if driver_key == "dameng" {
|
||||
validate_dameng_java_system_properties(extra_java_args)?;
|
||||
@@ -1129,7 +1146,9 @@ impl AgentManager {
|
||||
let driver_dir = self.driver_dir(driver_key);
|
||||
let config_path = self.driver_launch_config_path(driver_key);
|
||||
if config_path.exists() {
|
||||
return self.resolve_configured_agent_launch_spec(driver_key, &driver_dir, &config_path);
|
||||
return Ok(self
|
||||
.resolve_configured_agent_launch_spec(driver_key, &driver_dir, &config_path)?
|
||||
.with_env(env.iter().cloned()));
|
||||
}
|
||||
|
||||
let native_path = self.driver_native_path(driver_key);
|
||||
@@ -1145,7 +1164,7 @@ impl AgentManager {
|
||||
} else {
|
||||
(native_path, driver_dir)
|
||||
};
|
||||
return Ok(AgentLaunchSpec::new(native_path).with_working_dir(driver_dir));
|
||||
return Ok(AgentLaunchSpec::new(native_path).with_working_dir(driver_dir).with_env(env.iter().cloned()));
|
||||
}
|
||||
|
||||
let jar_path = self.driver_jar_path(driver_key);
|
||||
@@ -1156,7 +1175,8 @@ impl AgentManager {
|
||||
"{driver_key} driver jar is invalid or corrupt. Please reinstall it from the Driver Manager."
|
||||
));
|
||||
}
|
||||
return Ok(AgentLaunchSpec::java_jar_with_extra_args(java, jar_path, extra_java_args));
|
||||
return Ok(AgentLaunchSpec::java_jar_with_extra_args(java, jar_path, extra_java_args)
|
||||
.with_env(env.iter().cloned()));
|
||||
}
|
||||
|
||||
Err(format!("{driver_key} driver is not installed. Please install it from the Driver Manager."))
|
||||
@@ -1364,11 +1384,44 @@ impl AgentManager {
|
||||
crate::agent_runtime::spawn_connection_client(self, db_type, driver_profile, extra_java_args).await
|
||||
}
|
||||
|
||||
/// Spawns a dedicated agent process carrying `env` (Oracle OCI connections).
|
||||
pub async fn spawn_with_env(
|
||||
&self,
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
env: &[(String, String)],
|
||||
) -> Result<AgentDriverClient, String> {
|
||||
crate::agent_runtime::spawn_connection_client_with_env(self, db_type, driver_profile, &[], env).await
|
||||
}
|
||||
|
||||
/// One-shot daemon call variant that hands `env` to the agent process.
|
||||
pub async fn call_daemon_method_with_timeout_and_env<T: serde::de::DeserializeOwned + Send + 'static>(
|
||||
&self,
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
method: AgentMethod,
|
||||
params: serde_json::Value,
|
||||
timeout_duration: Option<Duration>,
|
||||
env: &[(String, String)],
|
||||
) -> Result<T, String> {
|
||||
crate::agent_runtime::call_daemon_method_with_timeout_and_env(
|
||||
self,
|
||||
db_type,
|
||||
driver_profile,
|
||||
method,
|
||||
params,
|
||||
timeout_duration,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn spawn_shared_connection_client(
|
||||
&self,
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
extra_java_args: &[String],
|
||||
agent_env: &[(String, String)],
|
||||
agent_session_id: String,
|
||||
connect_params: serde_json::Value,
|
||||
connect_timeout: std::time::Duration,
|
||||
@@ -1378,6 +1431,7 @@ impl AgentManager {
|
||||
db_type,
|
||||
driver_profile,
|
||||
extra_java_args,
|
||||
agent_env,
|
||||
agent_session_id,
|
||||
connect_params,
|
||||
connect_timeout,
|
||||
|
||||
@@ -52,7 +52,7 @@ pub async fn stop_daemon_by_key(manager: &AgentManager, agent_key: &str) {
|
||||
|
||||
pub async fn restart_daemon_by_key(manager: &AgentManager, agent_key: &str) -> Result<(), String> {
|
||||
manager.daemons.lock().await.remove(agent_key);
|
||||
let client = spawn_client_for_key(manager, agent_key, &[]).await?;
|
||||
let client = spawn_client_for_key(manager, agent_key, &[], &[]).await?;
|
||||
manager.daemons.lock().await.insert(agent_key.to_string(), client);
|
||||
Ok(())
|
||||
}
|
||||
@@ -62,10 +62,26 @@ pub async fn spawn_connection_client(
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
extra_java_args: &[String],
|
||||
) -> Result<AgentDriverClient, String> {
|
||||
spawn_connection_client_with_env(manager, db_type, driver_profile, extra_java_args, &[]).await
|
||||
}
|
||||
|
||||
/// Spawns a dedicated agent process whose environment carries `env`.
|
||||
///
|
||||
/// Oracle OCI ("thick") connections use this to hand process-scoped client
|
||||
/// settings (`NLS_LANG`, the Instant Client loader directory) to the agent:
|
||||
/// those cannot travel as connection parameters, and two connections that
|
||||
/// disagree about them must never share one process.
|
||||
pub async fn spawn_connection_client_with_env(
|
||||
manager: &AgentManager,
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
extra_java_args: &[String],
|
||||
env: &[(String, String)],
|
||||
) -> Result<AgentDriverClient, String> {
|
||||
let keys = runtime_agent_key_candidates(db_type, driver_profile)
|
||||
.ok_or_else(|| format!("{:?} is not an agent-driven database type", db_type))?;
|
||||
spawn_first_available_client(manager, &keys, extra_java_args).await
|
||||
spawn_first_available_client(manager, &keys, extra_java_args, env).await
|
||||
}
|
||||
|
||||
pub async fn spawn_shared_connection_client(
|
||||
@@ -73,6 +89,7 @@ pub async fn spawn_shared_connection_client(
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
extra_java_args: &[String],
|
||||
agent_env: &[(String, String)],
|
||||
agent_session_id: String,
|
||||
connect_params: serde_json::Value,
|
||||
connect_timeout: Duration,
|
||||
@@ -82,7 +99,7 @@ pub async fn spawn_shared_connection_client(
|
||||
let key = first_installed_agent_key(manager, &keys).unwrap_or(keys[0]);
|
||||
let state = manager.load_state();
|
||||
let jre_key = state.installed_drivers.get(key).map(|driver| driver.jre.as_str()).unwrap_or(DEFAULT_JRE_KEY);
|
||||
let launch = manager.resolve_agent_launch_spec_with_extra_args(&state, key, jre_key, extra_java_args)?;
|
||||
let launch = manager.resolve_agent_launch_spec_with_launch_env(&state, key, jre_key, extra_java_args, agent_env)?;
|
||||
let runtime_key = shared_runtime_key(key, &launch);
|
||||
let mut session_params = connect_params;
|
||||
session_params
|
||||
@@ -178,12 +195,17 @@ fn reserve_runtime_locked(
|
||||
}
|
||||
|
||||
fn shared_runtime_key(agent_key: &str, launch: &crate::db::agent_driver::AgentLaunchSpec) -> String {
|
||||
// `env` is part of the fingerprint on purpose: Oracle Client settings such
|
||||
// as `NLS_LANG` are process-scoped, so a connection that declares one must
|
||||
// not reuse a process started without it (or with a different value).
|
||||
let env = launch.env.iter().map(|(key, value)| format!("{key}={value}")).collect::<Vec<_>>().join("\u{1f}");
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
"{}|{}|{}|{}|{}",
|
||||
agent_key,
|
||||
launch.program.display(),
|
||||
launch.args.join("\u{1f}"),
|
||||
launch.working_dir.as_ref().map(|path| path.display().to_string()).unwrap_or_default()
|
||||
launch.working_dir.as_ref().map(|path| path.display().to_string()).unwrap_or_default(),
|
||||
env
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,7 +223,7 @@ pub async fn call_daemon<T: DeserializeOwned + Send + 'static>(
|
||||
let mut daemons = manager.daemons.lock().await;
|
||||
|
||||
if !daemons.contains_key(&key) {
|
||||
let client = spawn_client_for_key(manager, &key, &[]).await?;
|
||||
let client = spawn_client_for_key(manager, &key, &[], &[]).await?;
|
||||
daemons.insert(key.clone(), client);
|
||||
}
|
||||
|
||||
@@ -211,7 +233,7 @@ pub async fn call_daemon<T: DeserializeOwned + Send + 'static>(
|
||||
Err(err) => {
|
||||
log::warn!("[agent] daemon call failed, respawning: {err}");
|
||||
daemons.remove(&key);
|
||||
let mut new_client = spawn_client_for_key(manager, &key, &[]).await?;
|
||||
let mut new_client = spawn_client_for_key(manager, &key, &[], &[]).await?;
|
||||
let result = new_client.call::<T>(method, params).await?;
|
||||
daemons.insert(key, new_client);
|
||||
Ok(result)
|
||||
@@ -234,7 +256,7 @@ pub async fn call_daemon_with_timeout<T: DeserializeOwned + Send + 'static>(
|
||||
let mut daemons = manager.daemons.lock().await;
|
||||
|
||||
if !daemons.contains_key(&key) {
|
||||
let client = spawn_client_for_key(manager, &key, &[]).await?;
|
||||
let client = spawn_client_for_key(manager, &key, &[], &[]).await?;
|
||||
daemons.insert(key.clone(), client);
|
||||
}
|
||||
|
||||
@@ -244,7 +266,7 @@ pub async fn call_daemon_with_timeout<T: DeserializeOwned + Send + 'static>(
|
||||
Err(err) => {
|
||||
log::warn!("[agent] daemon call failed, respawning: {err}");
|
||||
daemons.remove(&key);
|
||||
let mut new_client = spawn_client_for_key(manager, &key, &[]).await?;
|
||||
let mut new_client = spawn_client_for_key(manager, &key, &[], &[]).await?;
|
||||
let result = new_client.call_with_timeout::<T>(method, params, timeout_duration).await?;
|
||||
daemons.insert(key, new_client);
|
||||
Ok(result)
|
||||
@@ -273,6 +295,32 @@ pub async fn call_daemon_method_with_timeout<T: DeserializeOwned + Send + 'stati
|
||||
call_daemon_with_timeout(manager, db_type, driver_profile, method.as_str(), params, timeout_duration).await
|
||||
}
|
||||
|
||||
/// Environment-aware sibling of [`call_daemon_method_with_timeout`].
|
||||
///
|
||||
/// A non-empty `env` gets a dedicated, short-lived process instead of the
|
||||
/// shared per-key daemon: process-scoped Oracle OCI settings (`NLS_LANG`, the
|
||||
/// Instant Client loader directory) must not leak into a process another
|
||||
/// connection is already using.
|
||||
pub async fn call_daemon_method_with_timeout_and_env<T: DeserializeOwned + Send + 'static>(
|
||||
manager: &AgentManager,
|
||||
db_type: &DatabaseType,
|
||||
driver_profile: Option<&str>,
|
||||
method: AgentMethod,
|
||||
params: serde_json::Value,
|
||||
timeout_duration: Option<Duration>,
|
||||
env: &[(String, String)],
|
||||
) -> Result<T, String> {
|
||||
if env.is_empty() {
|
||||
return call_daemon_with_timeout(manager, db_type, driver_profile, method.as_str(), params, timeout_duration)
|
||||
.await;
|
||||
}
|
||||
let keys = runtime_agent_key_candidates(db_type, driver_profile)
|
||||
.ok_or_else(|| format!("{:?} is not an agent-driven database type", db_type))?;
|
||||
let key = first_installed_agent_key(manager, &keys).unwrap_or(keys[0]).to_string();
|
||||
let mut client = spawn_client_for_key(manager, &key, &[], env).await?;
|
||||
client.call_with_timeout::<T>(method.as_str(), params, timeout_duration).await
|
||||
}
|
||||
|
||||
fn runtime_agent_key_candidates(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option<Vec<&'static str>> {
|
||||
let primary = db_type_to_agent_key(db_type, driver_profile)?;
|
||||
Some(vec![primary])
|
||||
@@ -284,12 +332,13 @@ fn first_installed_agent_key<'a>(manager: &AgentManager, keys: &'a [&'static str
|
||||
|
||||
async fn spawn_first_available_client(
|
||||
manager: &AgentManager,
|
||||
keys: &[&'static str],
|
||||
keys: &[&str],
|
||||
extra_java_args: &[String],
|
||||
env: &[(String, String)],
|
||||
) -> Result<AgentDriverClient, String> {
|
||||
let mut last_error = None;
|
||||
for key in keys {
|
||||
match spawn_client_for_key(manager, key, extra_java_args).await {
|
||||
match spawn_client_for_key(manager, key, extra_java_args, env).await {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
@@ -301,11 +350,14 @@ async fn spawn_client_for_key(
|
||||
manager: &AgentManager,
|
||||
key: &str,
|
||||
extra_java_args: &[String],
|
||||
env: &[(String, String)],
|
||||
) -> Result<AgentDriverClient, String> {
|
||||
let state = manager.load_state();
|
||||
let jre_key = state.installed_drivers.get(key).map(|driver| driver.jre.as_str()).unwrap_or(DEFAULT_JRE_KEY);
|
||||
|
||||
let launch = manager.resolve_agent_launch_spec_with_extra_args(&state, key, jre_key, extra_java_args)?;
|
||||
let launch = manager
|
||||
.resolve_agent_launch_spec_with_extra_args(&state, key, jre_key, extra_java_args)?
|
||||
.with_env(env.iter().cloned());
|
||||
let mut client = AgentDriverClient::spawn(launch).await?;
|
||||
client.try_optional_handshake(manager.agent_app_version()).await;
|
||||
Ok(client)
|
||||
@@ -375,6 +427,21 @@ for line in sys.stdin:
|
||||
assert_ne!(shared_runtime_key("oracle", &base), shared_runtime_key("oracle", &different_args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_runtime_key_isolates_process_scoped_env() {
|
||||
let base = crate::db::agent_driver::AgentLaunchSpec::new(PathBuf::from("oracle-agent"));
|
||||
let with_nls =
|
||||
base.clone().with_env([("NLS_LANG".to_string(), "SIMPLIFIED CHINESE_CHINA.AL32UTF8".to_string())]);
|
||||
let with_other_nls = base.clone().with_env([("NLS_LANG".to_string(), "AMERICAN_AMERICA.AL32UTF8".to_string())]);
|
||||
|
||||
// Setting a process-scoped variable must route to a dedicated process …
|
||||
assert_ne!(shared_runtime_key("oracle", &base), shared_runtime_key("oracle", &with_nls));
|
||||
// … and two different values must never share one process either.
|
||||
assert_ne!(shared_runtime_key("oracle", &with_nls), shared_runtime_key("oracle", &with_other_nls));
|
||||
// Identical env still reuses the same process.
|
||||
assert_eq!(shared_runtime_key("oracle", &with_nls), shared_runtime_key("oracle", &with_nls.clone()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_open_forgets_runtime_when_no_other_session_uses_it() {
|
||||
let (manager, cell, runtime, script_path) = test_shared_runtime("failed-open-unused").await;
|
||||
|
||||
@@ -461,13 +461,21 @@ pub fn build_agent_list(am: &AgentManager, registry: Option<&AgentRegistry>) ->
|
||||
let local_state = am.load_state();
|
||||
let use_managed_jre = local_state.java_runtime.mode == JavaRuntimeMode::Managed;
|
||||
agent_catalog::driver_store_entries()
|
||||
.map(|(key, label)| {
|
||||
.filter_map(|(key, label)| {
|
||||
let jar_valid = am.is_driver_jar_valid(key);
|
||||
let native_installed = am.driver_native_installed(key);
|
||||
let launch_config_installed = am.driver_launch_config_path(key).exists();
|
||||
let installed = jar_valid || native_installed || launch_config_installed;
|
||||
let local = local_state.installed_drivers.get(key);
|
||||
let remote = registry.and_then(|r| agent_registry_driver(r, key));
|
||||
// A driver the registry publishes nothing for on this platform (the
|
||||
// Windows-only Oracle OCI agent on macOS, for example) cannot be
|
||||
// installed here, and offering it would only produce a download or
|
||||
// JRE fetch that must fail. Already-installed entries stay listed so
|
||||
// they can still be upgraded or uninstalled.
|
||||
if !installed && remote.is_some_and(|driver| !driver_installable_on_current_platform(key, driver)) {
|
||||
return None;
|
||||
}
|
||||
let remote_requires_java_runtime = remote.is_some_and(remote_driver_requires_java_runtime);
|
||||
let requires_java_runtime = if installed {
|
||||
jar_valid && !native_installed && !launch_config_installed
|
||||
@@ -485,7 +493,7 @@ pub fn build_agent_list(am: &AgentManager, registry: Option<&AgentRegistry>) ->
|
||||
&& use_managed_jre
|
||||
&& (!am.is_jre_installed(&jre_key)
|
||||
|| remote_jre_version.is_some_and(|version| local_jre_version != Some(version)));
|
||||
AgentDriverInfo {
|
||||
Some(AgentDriverInfo {
|
||||
db_type: key.to_string(),
|
||||
label: label.to_string(),
|
||||
version: remote.map(|r| r.version.clone()).unwrap_or_default(),
|
||||
@@ -499,7 +507,7 @@ pub fn build_agent_list(am: &AgentManager, registry: Option<&AgentRegistry>) ->
|
||||
requires_java_runtime,
|
||||
jre: jre_key.clone(),
|
||||
jre_installed: !requires_java_runtime || am.is_jre_installed(&jre_key),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -508,6 +516,19 @@ fn usable_driver_jar(driver: &crate::agent_manager::DriverInfo) -> Option<&crate
|
||||
driver.jar.as_ref().filter(|artifact| artifact.size > 0)
|
||||
}
|
||||
|
||||
/// Whether the registry publishes an artifact this platform can actually run:
|
||||
/// a native agent binary built for it, or the Java JAR fallback.
|
||||
///
|
||||
/// The SQLite SSH worker is the one driver whose binaries execute on the remote
|
||||
/// SSH host instead of the desktop, so a `native` set without an entry for this
|
||||
/// platform is still installable here (#8987).
|
||||
fn driver_installable_on_current_platform(db_type: &str, driver: &crate::agent_manager::DriverInfo) -> bool {
|
||||
if AgentManager::is_sqlite_worker_driver(db_type) {
|
||||
return true;
|
||||
}
|
||||
driver.native.contains_key(AgentManager::current_platform()) || usable_driver_jar(driver).is_some()
|
||||
}
|
||||
|
||||
fn driver_download_artifact(driver: &crate::agent_manager::DriverInfo) -> Option<&crate::agent_manager::ArtifactInfo> {
|
||||
driver.native.get(AgentManager::current_platform()).or_else(|| usable_driver_jar(driver))
|
||||
}
|
||||
@@ -1738,7 +1759,11 @@ async fn install_agent_driver_from_registry(
|
||||
let jre_key = &driver.jre;
|
||||
let native_artifact = driver.native.get(AgentManager::current_platform());
|
||||
let jar_artifact = usable_driver_jar(driver);
|
||||
let requires_java_runtime = native_artifact.is_none();
|
||||
// Java is only required when the JAR fallback is what actually gets
|
||||
// installed. A driver that publishes no artifact for this platform at all
|
||||
// (the Windows-only Oracle OCI agent on macOS, for example) must fail
|
||||
// immediately instead of downloading and unpacking a JRE it cannot use.
|
||||
let requires_java_runtime = native_artifact.is_none() && jar_artifact.is_some();
|
||||
let needs_jre = requires_java_runtime && jre_needs_install(am, registry, jre_key);
|
||||
|
||||
if needs_jre {
|
||||
@@ -4901,6 +4926,45 @@ mod agent_registry_install_tests {
|
||||
assert_eq!(state.jre_versions[DEFAULT_JRE_KEY], jre_version);
|
||||
}
|
||||
|
||||
/// The managed JRE is only needed for the JAR fallback. A driver the registry
|
||||
/// publishes for another platform only — the Windows-only Oracle OCI agent on
|
||||
/// macOS — must fail on the missing artifact instead of fetching and
|
||||
/// unpacking a JRE it can never use.
|
||||
#[tokio::test]
|
||||
async fn ensure_agent_runtime_fails_without_fetching_a_jre_when_no_local_artifact_exists() {
|
||||
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
||||
let manager = test_manager("ensure-no-local-artifact");
|
||||
let db_type = "oracle-oci";
|
||||
let foreign_platform =
|
||||
if AgentManager::current_platform() == "windows-x64" { "linux-x64" } else { "windows-x64" };
|
||||
let mut registry = registry_with_jar(
|
||||
db_type,
|
||||
"0.1.0",
|
||||
"https://example.invalid/dbx-agent-oracle-oci-legacy-placeholder.jar",
|
||||
0,
|
||||
);
|
||||
registry.jres = registry_with_jre(DEFAULT_JRE_KEY, "21.0.12", "https://example.invalid/dbx-jre.tar.gz", 8).jres;
|
||||
registry.drivers.get_mut(db_type).unwrap().native.insert(
|
||||
foreign_platform.to_string(),
|
||||
ArtifactInfo {
|
||||
url: format!("https://example.invalid/dbx-agent-oracle-oci-{foreign_platform}"),
|
||||
sha256: None,
|
||||
size: 8,
|
||||
format: None,
|
||||
},
|
||||
);
|
||||
cache_test_registry(registry).await;
|
||||
|
||||
let error = ensure_agent_driver_ready_from(&manager, db_type, DownloadSource::Cnb).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("No driver artifact available"), "{error}");
|
||||
assert!(
|
||||
!manager.is_jre_installed(DEFAULT_JRE_KEY),
|
||||
"the JRE must not be downloaded for an uninstallable driver"
|
||||
);
|
||||
assert!(!manager.is_driver_installed(db_type));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_agent_runtime_ready_fast_path_preserves_valid_old_driver() {
|
||||
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod agent_runtime;
|
||||
pub mod agent_service;
|
||||
pub mod backend_error;
|
||||
pub mod database_capabilities;
|
||||
pub mod oracle_oci;
|
||||
|
||||
pub mod db {
|
||||
pub use crate::agent_driver;
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Launch environment for the Oracle OCI ("thick") driver.
|
||||
//!
|
||||
//! The Oracle Client is a process-scoped C library: `NLS_LANG` is read once when
|
||||
//! the client initialises, and the Instant Client shared library is resolved
|
||||
//! through the platform loader path. Neither can be changed after the process
|
||||
//! has started, so a connection that declares either setting must get its own
|
||||
//! agent process.
|
||||
//!
|
||||
//! The entries produced here are folded into
|
||||
//! [`crate::agent_driver::AgentLaunchSpec::env`], which
|
||||
//! [`crate::agent_runtime::shared_runtime_key`] hashes. That is the whole
|
||||
//! isolation mechanism: distinct environments land on distinct runtime keys,
|
||||
//! hence distinct processes, with no extra bookkeeping at the call site.
|
||||
|
||||
/// `driver_profile` value that selects the OCI (thick) driver for Oracle.
|
||||
pub const ORACLE_OCI_DRIVER_PROFILE: &str = "oci";
|
||||
|
||||
/// Client-side character set and territory, read by the Oracle Client on init.
|
||||
pub const NLS_LANG_ENV: &str = "NLS_LANG";
|
||||
|
||||
/// Directory holding `tnsnames.ora` / `sqlnet.ora`.
|
||||
pub const TNS_ADMIN_ENV: &str = "TNS_ADMIN";
|
||||
|
||||
/// Loader path variable the Oracle Client shared library is resolved through.
|
||||
pub fn oracle_client_lib_path_var() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"PATH"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"DYLD_LIBRARY_PATH"
|
||||
} else {
|
||||
"LD_LIBRARY_PATH"
|
||||
}
|
||||
}
|
||||
|
||||
fn path_separator() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
";"
|
||||
} else {
|
||||
":"
|
||||
}
|
||||
}
|
||||
|
||||
fn trimmed_non_empty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Prepends `client_dir` to an existing loader path value.
|
||||
///
|
||||
/// Already-present entries are left alone so repeated resolution cannot grow the
|
||||
/// variable without bound.
|
||||
pub fn prepend_oracle_client_dir(current: Option<&str>, client_dir: &str) -> String {
|
||||
let client_dir = client_dir.trim();
|
||||
let separator = path_separator();
|
||||
match trimmed_non_empty(current) {
|
||||
Some(existing) if !existing.split(separator).any(|entry| entry.trim() == client_dir) => {
|
||||
format!("{client_dir}{separator}{existing}")
|
||||
}
|
||||
Some(existing) => existing.to_string(),
|
||||
None => client_dir.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the agent-process environment for an Oracle OCI connection.
|
||||
///
|
||||
/// Returns an empty vector when nothing is configured, which keeps the launch
|
||||
/// fingerprint identical to a plain Oracle launch so the default process stays
|
||||
/// shared.
|
||||
pub fn oracle_oci_launch_env(
|
||||
nls_lang: Option<&str>,
|
||||
client_dir: Option<&str>,
|
||||
tns_admin: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env = Vec::new();
|
||||
if let Some(nls_lang) = trimmed_non_empty(nls_lang) {
|
||||
env.push((NLS_LANG_ENV.to_string(), nls_lang.to_string()));
|
||||
}
|
||||
if let Some(tns_admin) = trimmed_non_empty(tns_admin) {
|
||||
env.push((TNS_ADMIN_ENV.to_string(), tns_admin.to_string()));
|
||||
}
|
||||
if let Some(client_dir) = trimmed_non_empty(client_dir) {
|
||||
let var = oracle_client_lib_path_var();
|
||||
let current = std::env::var(var).ok();
|
||||
env.push((var.to_string(), prepend_oracle_client_dir(current.as_deref(), client_dir)));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// JDBC URL prefixes of the two Oracle protocols. The connection dialog always
|
||||
/// stores the thin form; an OCI connection has to launch the same target with
|
||||
/// the `oci8` protocol instead.
|
||||
const ORACLE_THIN_URL_PREFIX: &str = "jdbc:oracle:thin:";
|
||||
const ORACLE_OCI_URL_PREFIX: &str = "jdbc:oracle:oci8:";
|
||||
|
||||
/// Whether `config` selects the OCI (thick) driver.
|
||||
pub fn uses_oracle_oci_profile(config: &crate::models::connection::ConnectionConfig) -> bool {
|
||||
config.driver_profile.as_deref() == Some(ORACLE_OCI_DRIVER_PROFILE)
|
||||
}
|
||||
|
||||
/// Switches an Oracle JDBC URL between the thin and OCI protocol.
|
||||
///
|
||||
/// Unknown shapes are returned trimmed but unchanged, so a hand-written URL
|
||||
/// (connect descriptor, TNS alias, `//host:port/service`) keeps working.
|
||||
pub fn rewrite_oracle_url_protocol(url: &str, oci: bool) -> String {
|
||||
let trimmed = url.trim();
|
||||
let (from, to) = if oci {
|
||||
(ORACLE_THIN_URL_PREFIX, ORACLE_OCI_URL_PREFIX)
|
||||
} else {
|
||||
(ORACLE_OCI_URL_PREFIX, ORACLE_THIN_URL_PREFIX)
|
||||
};
|
||||
if trimmed.len() >= from.len() && trimmed[..from.len()].eq_ignore_ascii_case(from) {
|
||||
return format!("{to}{}", &trimmed[from.len()..]);
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Drops a query string that only carries `TNS_ADMIN`.
|
||||
///
|
||||
/// OCI reads the directory from the `TNS_ADMIN` environment variable of the
|
||||
/// agent process (see [`oracle_oci_launch_env`]), so the dialog's
|
||||
/// `?TNS_ADMIN=…` suffix is redundant there. Any other parameter is preserved.
|
||||
pub fn strip_oracle_tns_admin_query(url: &str) -> String {
|
||||
let Some((base, query)) = url.split_once('?') else {
|
||||
return url.to_string();
|
||||
};
|
||||
let pairs: Vec<&str> = query.split('&').filter(|pair| !pair.trim().is_empty()).collect();
|
||||
let only_tns_admin = !pairs.is_empty()
|
||||
&& pairs.iter().all(|pair| pair.split('=').next().is_some_and(|key| key.eq_ignore_ascii_case("TNS_ADMIN")));
|
||||
if only_tns_admin {
|
||||
base.to_string()
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the `TNS_ADMIN` directory the TNS connection form packs into the URL.
|
||||
pub fn oracle_tns_admin_from_connection_string(connection_string: &str) -> Option<String> {
|
||||
let (_, query) = connection_string.trim().split_once('?')?;
|
||||
query.split('&').find_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
if !key.eq_ignore_ascii_case("TNS_ADMIN") {
|
||||
return None;
|
||||
}
|
||||
let decoded = percent_decode(value.trim());
|
||||
(!decoded.is_empty()).then_some(decoded)
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 OCI 连接使用的 `TNS_ADMIN` 目录。
|
||||
///
|
||||
/// 优先级:连接级字段 > TNS 连接串里打包的目录 > 全局默认。连接串里的目录
|
||||
/// 来自 TNS 连接方式(前端把它打包进 JDBC URL),属于本连接自己的配置,
|
||||
/// 因此优先于全局默认。
|
||||
pub fn resolve_oci_tns_admin(
|
||||
explicit: Option<&str>,
|
||||
connection_string: Option<&str>,
|
||||
global_default: Option<&str>,
|
||||
) -> Option<String> {
|
||||
trimmed_non_empty(explicit)
|
||||
.map(str::to_string)
|
||||
.or_else(|| connection_string.and_then(oracle_tns_admin_from_connection_string))
|
||||
.or_else(|| trimmed_non_empty(global_default).map(str::to_string))
|
||||
}
|
||||
|
||||
/// Minimal `application/x-www-form-urlencoded` decoder (`%XX` escapes and `+`).
|
||||
fn percent_decode(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
index += 1;
|
||||
}
|
||||
b'%' if index + 3 <= bytes.len() => {
|
||||
let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
|
||||
.ok()
|
||||
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
|
||||
match hex {
|
||||
Some(byte) => {
|
||||
out.push(byte);
|
||||
index += 3;
|
||||
}
|
||||
None => {
|
||||
out.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
byte => {
|
||||
out.push(byte);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unconfigured_oci_launch_shares_the_default_process() {
|
||||
assert!(oracle_oci_launch_env(None, None, None).is_empty());
|
||||
assert!(oracle_oci_launch_env(Some(" "), Some(""), None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nls_lang_is_process_scoped_environment() {
|
||||
let env = oracle_oci_launch_env(Some(" SIMPLIFIED CHINESE_CHINA.AL32UTF8 "), None, None);
|
||||
assert_eq!(env, vec![(NLS_LANG_ENV.to_string(), "SIMPLIFIED CHINESE_CHINA.AL32UTF8".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tns_admin_is_passed_through() {
|
||||
let env = oracle_oci_launch_env(None, None, Some("/opt/oracle/network/admin"));
|
||||
assert_eq!(env, vec![(TNS_ADMIN_ENV.to_string(), "/opt/oracle/network/admin".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_dir_is_prepended_to_the_loader_path() {
|
||||
let env = oracle_oci_launch_env(None, Some("C:/instantclient_21_12"), None);
|
||||
let var = oracle_client_lib_path_var();
|
||||
assert_eq!(env.len(), 1);
|
||||
assert_eq!(env[0].0, var);
|
||||
assert!(env[0].1.starts_with("C:/instantclient_21_12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_uses_the_platform_separator() {
|
||||
let separator = path_separator();
|
||||
assert_eq!(
|
||||
prepend_oracle_client_dir(Some("/usr/lib"), "/opt/oracle"),
|
||||
format!("/opt/oracle{separator}/usr/lib")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_is_idempotent() {
|
||||
let once = prepend_oracle_client_dir(Some("/usr/lib"), "/opt/oracle");
|
||||
assert_eq!(prepend_oracle_client_dir(Some(&once), "/opt/oracle"), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_without_current_value_returns_client_dir() {
|
||||
assert_eq!(prepend_oracle_client_dir(None, "/opt/oracle"), "/opt/oracle");
|
||||
assert_eq!(prepend_oracle_client_dir(Some(" "), "/opt/oracle"), "/opt/oracle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oci_profile_matches_the_connection_type_declaration() {
|
||||
assert_eq!(ORACLE_OCI_DRIVER_PROFILE, "oci");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_urls_are_rewritten_to_the_oci_protocol() {
|
||||
assert_eq!(
|
||||
rewrite_oracle_url_protocol("jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1", true),
|
||||
"jdbc:oracle:oci8:@//db.example.com:1521/ORCLPDB1"
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_oracle_url_protocol("jdbc:oracle:thin:@db.example.com:1521:ORCL", true),
|
||||
"jdbc:oracle:oci8:@db.example.com:1521:ORCL"
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_oracle_url_protocol(
|
||||
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1521)))",
|
||||
true
|
||||
),
|
||||
"jdbc:oracle:oci8:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1521)))"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oci_urls_are_rewritten_back_to_thin() {
|
||||
assert_eq!(
|
||||
rewrite_oracle_url_protocol("jdbc:oracle:oci8:@//db.example.com:1521/ORCLPDB1", false),
|
||||
"jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_url_shapes_are_left_alone() {
|
||||
assert_eq!(rewrite_oracle_url_protocol("jdbc:postgresql://db/app", true), "jdbc:postgresql://db/app");
|
||||
assert_eq!(rewrite_oracle_url_protocol("", true), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tns_admin_prefers_the_connection_field_over_the_url_and_global() {
|
||||
assert_eq!(
|
||||
resolve_oci_tns_admin(
|
||||
Some("C:/wallets/adb"),
|
||||
Some("jdbc:oracle:oci8:@ALIAS?TNS_ADMIN=C:/from-url"),
|
||||
Some("C:/global"),
|
||||
)
|
||||
.as_deref(),
|
||||
Some("C:/wallets/adb")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tns_admin_falls_back_to_the_url_then_the_global_default() {
|
||||
assert_eq!(
|
||||
resolve_oci_tns_admin(None, Some("jdbc:oracle:oci8:@ALIAS?TNS_ADMIN=C:/from-url"), Some("C:/global"),)
|
||||
.as_deref(),
|
||||
Some("C:/from-url")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_oci_tns_admin(None, Some("jdbc:oracle:oci8:@ALIAS"), Some("C:/global")).as_deref(),
|
||||
Some("C:/global")
|
||||
);
|
||||
assert_eq!(resolve_oci_tns_admin(None, None, None), None);
|
||||
assert_eq!(resolve_oci_tns_admin(Some(" "), None, Some("C:/global")).as_deref(), Some("C:/global"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tns_admin_query_is_read_and_decoded() {
|
||||
assert_eq!(
|
||||
oracle_tns_admin_from_connection_string(
|
||||
"jdbc:oracle:thin:@ORCLPDB1?TNS_ADMIN=C%3A%5Coracle%5Cnetwork%5Cadmin"
|
||||
),
|
||||
Some("C:\\oracle\\network\\admin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
oracle_tns_admin_from_connection_string("jdbc:oracle:thin:@ORCLPDB1?TNS_ADMIN=/opt/oracle/admin"),
|
||||
Some("/opt/oracle/admin".to_string())
|
||||
);
|
||||
assert_eq!(oracle_tns_admin_from_connection_string("jdbc:oracle:thin:@//host:1521/svc"), None);
|
||||
assert_eq!(oracle_tns_admin_from_connection_string(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tns_admin_query_is_stripped_only_when_it_stands_alone() {
|
||||
assert_eq!(
|
||||
strip_oracle_tns_admin_query("jdbc:oracle:oci8:@ORCLPDB1?TNS_ADMIN=/opt/oracle/admin"),
|
||||
"jdbc:oracle:oci8:@ORCLPDB1"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_oracle_tns_admin_query("jdbc:oracle:oci8:@ORCLPDB1?TNS_ADMIN=/opt/oracle/admin&other=1"),
|
||||
"jdbc:oracle:oci8:@ORCLPDB1?TNS_ADMIN=/opt/oracle/admin&other=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7554,6 +7554,8 @@ mod tests {
|
||||
|
||||
fn redis_test_connection_config() -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "redis".to_string(),
|
||||
name: "Redis".to_string(),
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use dbx_types::{database_manifest, models, types};
|
||||
|
||||
pub use dbx_driver_agent::{
|
||||
agent_catalog, agent_connection, agent_manager, agent_offline_export, agent_recovery, agent_runtime, agent_service,
|
||||
backend_error, database_capabilities,
|
||||
backend_error, database_capabilities, oracle_oci,
|
||||
};
|
||||
pub mod db;
|
||||
pub use dbx_driver_support::execution;
|
||||
|
||||
@@ -189,6 +189,21 @@ pub struct ConnectionConfig {
|
||||
pub sysdba: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oracle_connection_type: Option<String>,
|
||||
/// Connection-level NLS_LANG override for OCI connections. Empty keeps the
|
||||
/// global default; a value makes this connection own a dedicated agent
|
||||
/// process so its client charset cannot bleed into other sessions (the
|
||||
/// variable is process-scoped for OCI).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oracle_oci_nls_lang: Option<String>,
|
||||
/// Connection-level `TNS_ADMIN` override for OCI (thick) connections.
|
||||
///
|
||||
/// Points at the directory holding `tnsnames.ora` / `sqlnet.ora` / the
|
||||
/// wallet, so any OCI connection can use an ADB wallet or network options
|
||||
/// without switching to the TNS connection form. Resolved against the
|
||||
/// global default and the TNS connection string in
|
||||
/// `dbx_core::connection::AppState::agent_launch_env`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oracle_oci_tns_admin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub connection_string: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -645,6 +660,10 @@ struct ConnectionConfigData {
|
||||
#[serde(default)]
|
||||
pub oracle_connection_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub oracle_oci_nls_lang: Option<String>,
|
||||
#[serde(default)]
|
||||
pub oracle_oci_tns_admin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub connection_string: Option<String>,
|
||||
#[serde(default)]
|
||||
pub redis_connection_mode: Option<String>,
|
||||
@@ -741,6 +760,8 @@ impl From<ConnectionConfigData> for ConnectionConfig {
|
||||
client_key_path: data.client_key_path,
|
||||
sysdba: data.sysdba,
|
||||
oracle_connection_type: data.oracle_connection_type,
|
||||
oracle_oci_nls_lang: data.oracle_oci_nls_lang,
|
||||
oracle_oci_tns_admin: data.oracle_oci_tns_admin,
|
||||
connection_string: data.connection_string,
|
||||
redis_connection_mode: data.redis_connection_mode,
|
||||
redis_sentinel_master: data.redis_sentinel_master,
|
||||
@@ -2823,6 +2844,8 @@ mod tests {
|
||||
|
||||
fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "id".to_string(),
|
||||
name: "name".to_string(),
|
||||
|
||||
@@ -1050,6 +1050,8 @@ mod tests {
|
||||
|
||||
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: "SQLite".to_string(),
|
||||
|
||||
@@ -239,6 +239,8 @@ mod tests {
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
connection_string: None,
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
|
||||
@@ -679,6 +679,8 @@ mod tests {
|
||||
|
||||
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: id.to_string(),
|
||||
name: "SQLite".to_string(),
|
||||
|
||||
@@ -51,6 +51,15 @@ Desktop 的文件路径属于本机。Docker/Web 的文件路径属于服务器
|
||||
|
||||
驱动的安装、更新、离线包和运行时监控见 [驱动管理](/cn/docs/driver-management);自定义 JDBC 见 [JDBC 插件](/cn/docs/plugins)。
|
||||
|
||||
### Oracle OCI(thick 驱动)
|
||||
|
||||
Oracle 连接默认走 thin 驱动;连接对话框的驱动模式里还可以选 **OCI**,它由独立的 `oracle-oci` Agent(基于 Oracle 调用接口构建的 Go agent)承担,连接由 Oracle 客户端库建立。OCI 驱动目前只提供 Windows x64 版本。
|
||||
|
||||
- **Instant Client 目录是全局设置**:填写包含 `oci.dll` 的目录,所有 OCI 连接共用;该目录会被加到 Agent 进程的动态库搜索路径最前面。
|
||||
- **NLS_LANG 在 Oracle Client 里是进程级的**,所以分两层配置:一个全局默认值,加上可选的连接级覆盖。一旦某个连接覆盖了 NLS_LANG,它就会独占一个 Agent 进程,字符集不会在连接之间串。
|
||||
- **TNS 别名与钱包 / sqlnet.ora**:`TNS_ADMIN` 支持全局默认与连接级覆盖(OCI 模式的连接表单里填写目录,留空跟随全局),任何 OCI 连接都能使用 tnsnames.ora 别名、ADB 钱包与 sqlnet.ora 网络选项(压缩、加密),不必切换到 TNS 连接方式;TNS 连接方式下目录也会随连接串传给 Agent 进程。
|
||||
- OCI 只改变客户端库的加载方式;网格编辑、DDL、传输等功能与 thin 驱动一致。
|
||||
|
||||
## 能力等级
|
||||
|
||||
DBX 驱动清单使用从连接到操作的能力等级,并允许按数据库覆盖单项能力:
|
||||
|
||||
@@ -51,6 +51,15 @@ These connections use key-value, document, search, vector, messaging, or configu
|
||||
|
||||
See [Driver Management](/en/docs/driver-management) for installation, upgrades, offline packages, and runtime monitoring, and [JDBC Plugin](/en/docs/plugins) for custom JDBC.
|
||||
|
||||
### Oracle OCI (thick driver)
|
||||
|
||||
Oracle connections default to the thin driver. The connection dialog's driver mode also offers **OCI**, which runs the separate `oracle-oci` Agent built against the Oracle Call Interface so the Oracle Client handles the connection. The OCI driver is currently shipped for Windows x64.
|
||||
|
||||
- **Instant Client directory** is a global setting: enter the directory that contains `oci.dll` once, and every OCI connection reuses it. The directory is prepended to the Agent process's loader path.
|
||||
- **NLS_LANG** is process-scoped for the Oracle Client, so it is configured in two layers: a global default, plus an optional per-connection override. A connection that overrides NLS_LANG gets its own Agent process instead of sharing one, so character sets never leak between connections.
|
||||
- **TNS names, wallets, and sqlnet.ora**: `TNS_ADMIN` supports a global default plus a per-connection override (fill the directory in the OCI connection form, or leave it empty to follow the global default), so any OCI connection can use tnsnames.ora aliases, ADB wallets, and sqlnet.ora network options (compression, encryption) without switching to the TNS connection form; the TNS connection form also passes its directory to the Agent process.
|
||||
- OCI only affects how the client library is loaded. Grid editing, DDL, transfer, and other features behave the same as on the thin driver.
|
||||
|
||||
## Capability Levels
|
||||
|
||||
The driver manifest uses capability levels from basic connection through controlled operations, with per-engine overrides:
|
||||
|
||||
@@ -186,7 +186,7 @@ test("RabbitMQ native tar.zst packages appear in the native catalog", () => {
|
||||
});
|
||||
|
||||
test("all current native-only agent packages appear in the native catalog", () => {
|
||||
const nativeKeys = ["cassandra", "duckdb", "hive", "iotdb", "kingbase", "neo4j", "oracle", "rabbitmq", "rocketmq", "tdengine", "vastbase", "xugu", "zookeeper"];
|
||||
const nativeKeys = ["cassandra", "duckdb", "hive", "iotdb", "kingbase", "neo4j", "oracle", "oracle-oci", "rabbitmq", "rocketmq", "tdengine", "vastbase", "xugu", "zookeeper"];
|
||||
const entries = buildNativeAgentEntries(
|
||||
nativeKeys.map((key) => ({
|
||||
name: `dbx-agent-${key}-${driverVersions[key as keyof typeof driverVersions]}-macos-aarch64.tar.zst`,
|
||||
@@ -200,4 +200,7 @@ test("all current native-only agent packages appear in the native catalog", () =
|
||||
assert.equal(entries.find(({ key }) => key === "hive")?.label, "Apache Hive");
|
||||
assert.equal(entries.find(({ key }) => key === "rocketmq")?.label, "Apache RocketMQ");
|
||||
assert.equal(entries.find(({ key }) => key === "zookeeper")?.label, "Apache ZooKeeper");
|
||||
assert.equal(entries.find(({ key }) => key === "oracle")?.label, "Oracle");
|
||||
// The OCI (thick driver) agent is a separate package from the thin one.
|
||||
assert.equal(entries.find(({ key }) => key === "oracle-oci")?.label, "Oracle (OCI)");
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ const driverLabels: Record<string, string> = {
|
||||
neo4j: "Neo4j",
|
||||
"oceanbase-oracle": "OceanBase Oracle Mode",
|
||||
oracle: "Oracle",
|
||||
"oracle-oci": "Oracle (OCI)",
|
||||
rabbitmq: "RabbitMQ",
|
||||
rocketmq: "Apache RocketMQ",
|
||||
saphana: "SAP HANA",
|
||||
|
||||
@@ -18,6 +18,13 @@ driverProfiles:
|
||||
agentKey: oracle
|
||||
label: Oracle
|
||||
storeVisible: false
|
||||
# OCI thick-driver mode: user selects it per connection; the oci.dll /
|
||||
# Instant Client location is a global setting shared by every OCI connection.
|
||||
- profile: oci
|
||||
agentKey: oracle-oci
|
||||
label: Oracle (OCI)
|
||||
storeVisible: true
|
||||
storeOrder: 54
|
||||
singleConnectionPool: true
|
||||
metadataConnectionScoped: false
|
||||
skipTcpProbe: true
|
||||
|
||||
@@ -111,14 +111,18 @@ async fn test_agent_connection(
|
||||
port: u16,
|
||||
) -> Result<ConnectionTestResult, String> {
|
||||
let connect_params = agent_connect_params(config, host, port, config.database.as_deref().unwrap_or(""))?;
|
||||
// Oracle OCI connections carry process-scoped client settings (`NLS_LANG`,
|
||||
// Instant Client loader path): they have to reach the agent process itself.
|
||||
let agent_env = state.agent_launch_env(config).await;
|
||||
let result = state
|
||||
.agent_manager
|
||||
.call_daemon_method_with_timeout::<serde_json::Value>(
|
||||
.call_daemon_method_with_timeout_and_env::<serde_json::Value>(
|
||||
&config.db_type,
|
||||
config.driver_profile.as_deref(),
|
||||
AgentMethod::TestConnection,
|
||||
connect_params,
|
||||
Some(agent_connect_timeout(config)),
|
||||
&agent_env,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -126,9 +130,10 @@ async fn test_agent_connection(
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
if let Some(alternate_config) = oracle_alternate_connect_config(config, &err) {
|
||||
let alternate_env = state.agent_launch_env(&alternate_config).await;
|
||||
state
|
||||
.agent_manager
|
||||
.call_daemon_method_with_timeout::<serde_json::Value>(
|
||||
.call_daemon_method_with_timeout_and_env::<serde_json::Value>(
|
||||
&alternate_config.db_type,
|
||||
alternate_config.driver_profile.as_deref(),
|
||||
AgentMethod::TestConnection,
|
||||
@@ -139,6 +144,7 @@ async fn test_agent_connection(
|
||||
alternate_config.database.as_deref().unwrap_or(""),
|
||||
)?,
|
||||
Some(agent_connect_timeout(&alternate_config)),
|
||||
&alternate_env,
|
||||
)
|
||||
.await
|
||||
.map_err(|alternate_err| {
|
||||
@@ -174,7 +180,9 @@ async fn connect_agent_pool(
|
||||
port: u16,
|
||||
) -> Result<PoolKind, String> {
|
||||
let connect_params = agent_connect_params(config, host, port, config.effective_database().unwrap_or(""))?;
|
||||
let mut client = state.agent_manager.spawn(&config.db_type, config.driver_profile.as_deref()).await?;
|
||||
let agent_env = state.agent_launch_env(config).await;
|
||||
let mut client =
|
||||
state.agent_manager.spawn_with_env(&config.db_type, config.driver_profile.as_deref(), &agent_env).await?;
|
||||
let connect_result = client
|
||||
.call_method_with_timeout::<serde_json::Value>(
|
||||
AgentMethod::Connect,
|
||||
@@ -223,6 +231,8 @@ mod tests {
|
||||
|
||||
fn mongodb_config() -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
oracle_oci_nls_lang: None,
|
||||
oracle_oci_tns_admin: None,
|
||||
docs_notes_path: None,
|
||||
id: "mongo".to_string(),
|
||||
name: "MongoDB".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user