commit 173d38e68805780b22322d8cba9f80538c98846d Author: wehub-resource-sync Date: Mon Jul 13 13:09:14 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..c7ddd21 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,21 @@ +# === Optional build speed-ups (add to your ~/.zshrc or ~/.bashrc; do NOT commit) === +# +# 1. sccache — compile cache, skips recompiling unchanged deps +# cargo install sccache +# export RUSTC_WRAPPER=sccache +# +# 2. lld — LLVM linker, 2-3x faster linking than the system default +# macOS: brew install lld +# export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/opt/homebrew/bin/lld +# Linux: apt install lld +# export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=lld +# +# Skip DuckDB during development: cargo check --no-default-features +# =================================================================================== + +[env] +# Enable SQLite's built-in math functions for rusqlite's bundled libsqlite3. +LIBSQLITE3_FLAGS = "SQLITE_ENABLE_MATH_FUNCTIONS" + +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c89d144 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Keep formatter-sensitive source files consistent across platforms. +apps/desktop/src/**/*.ts text eol=lf +apps/desktop/src/**/*.vue text eol=lf + +# Keep tests available for review and CI without counting them as shipped code +# in GitHub's repository language breakdown. +packages/app-tests/** linguist-vendored +apps/desktop/src/**/__tests__/** linguist-vendored +apps/desktop/src/**/*.spec.ts linguist-vendored +apps/desktop/src/**/*.test.ts linguist-vendored +packages/**/tests/** linguist-vendored +packages/**/*.spec.ts linguist-vendored +packages/**/*.test.ts linguist-vendored diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1254569 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: "🐞 Bug Report / 问题反馈" +description: Crashes, connection failures, query errors, display bugs, or other reproducible issues. / 程序崩溃、连接失败、查询异常、界面显示错误或其他可复现问题。 +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 感谢反馈。涉及敏感信息请脱敏。请尽量提供数据库类型、版本、复现步骤、截图和错误日志。 + DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。 + + - type: input + id: database + attributes: + label: 数据库类型和版本 + description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。 + placeholder: "例如:PostgreSQL 16.2" + validations: + required: true + + - type: textarea + id: description + attributes: + label: 问题描述 + description: 请按下面的清单逐项填写,截图和错误日志尤其重要。 + placeholder: | + 1. 复现步骤:新建连接 → 填写信息 → 点击保存 + 2. 期望结果:能正常展开 schema + 3. 实际结果:一直 loading,无错误提示 + 4. 最好附上截图和错误日志(设置 → 外观 → 启用"启用调试日志" → 复现问题 → 点击"下载日志",将内容粘贴到这里) + validations: + required: true + + - type: textarea + id: support-info + attributes: + label: 支持信息 + description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 + placeholder: | + DBX 版本:v0.5.52 + 运行环境:桌面端 + 操作系统:macOS 26.4 + 系统架构:aarch64 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: 优先级(是否紧急) + description: 紧急 = 希望下个版本优先修。维护者可根据实际情况调整。 + options: + - "🔴 P0 紧急 · 下个版本必须修" + - "🟠 P1 重要 · 尽快处理" + - "🟡 P2 普通 · 按排期处理" + - "🟢 P3 低优 · 有空再修" + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 补充信息 + description: 截图、SQL 样例、连接方式、特殊环境等。 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5e562b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation / 文档 + url: https://dbxio.com/cn/docs/what-is-dbx + about: Check the docs before submitting an issue. / 提交 issue 前可先查看文档。 diff --git a/.github/ISSUE_TEMPLATE/database_compatibility.yml b/.github/ISSUE_TEMPLATE/database_compatibility.yml new file mode 100644 index 0000000..7a4f60d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/database_compatibility.yml @@ -0,0 +1,55 @@ +name: "🔀 Compatibility Issue / 数据库兼容性问题" +description: A specific database, version, or SQL dialect incompatibility. / 某个数据库、版本或 SQL 方言不兼容。 +title: "[Compatibility] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 用于收集数据库兼容性细节,例如连接失败、schema 读取不完整、字段类型显示错误等。请脱敏。 + DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。 + + - type: input + id: database + attributes: + label: 数据库类型和版本 + description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。 + placeholder: "例如:MariaDB 10.11" + validations: + required: true + + - type: textarea + id: description + attributes: + label: 问题描述 + description: 什么操作,出了什么错,期望怎样? + placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。" + validations: + required: true + + - type: textarea + id: error + attributes: + label: 错误信息 + render: shell + validations: + required: true + + - type: textarea + id: support-info + attributes: + label: 支持信息 + description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 + placeholder: | + DBX 版本:v0.5.52 + 运行环境:桌面端 + 操作系统:macOS 26.4 + 系统架构:aarch64 + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 补充信息 + description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..d968ee2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,69 @@ +name: "💡 Feature Request / 功能建议" +description: Propose a new feature, interaction improvement, or enhancement. / 提议新功能、交互改进或现有功能增强。 +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + 好的建议最好说明使用场景、数据库类型和期望效果。请尽量避免只写“支持 X”。 + DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。 + + - type: input + id: database + attributes: + label: 数据库类型和版本 + description: 如果是通用功能,请填写“通用”。如果只影响特定数据源,请注明数据库类型、版本或发行版。 + placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用" + validations: + required: true + + - type: textarea + id: support-info + attributes: + label: 支持信息 + description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 + placeholder: | + DBX 版本:v0.5.52 + 运行环境:桌面端 + 操作系统:macOS 26.4 + 系统架构:aarch64 + validations: + required: true + + - type: textarea + id: problem + attributes: + label: 当前痛点 + description: 现在做这件事有什么不方便? + placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。" + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: 期望方案 + description: 希望怎么支持? + placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。" + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: 优先级(是否紧急) + description: 紧急 = 希望下个版本优先做。维护者可根据实际情况调整。 + options: + - "🔴 P0 紧急 · 下个版本必须做" + - "🟠 P1 重要 · 尽快处理" + - "🟡 P2 普通 · 按排期处理" + - "🟢 P3 低优 · 有空再做" + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 补充信息 + description: 参考截图、替代方案、特殊环境等。 diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..c03f4da --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,44 @@ +name: "❓ Question / 问题咨询" +description: Usage questions, configuration issues, or feedback you're not sure is a bug. / 使用疑问、配置问题或不确定是否为 bug 的反馈。 +title: "[Question] " +labels: ["question"] +body: + - type: input + id: database + attributes: + label: 数据库类型和版本 + description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。 + placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 无关" + validations: + required: true + + - type: textarea + id: support-info + attributes: + label: 支持信息 + description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 + placeholder: | + DBX 版本:v0.5.52 + 运行环境:桌面端 + 操作系统:macOS 26.4 + 系统架构:aarch64 + validations: + required: true + + - type: textarea + id: goal + attributes: + label: 描述 + description: 你想完成什么?已经尝试了什么? + placeholder: "例如:我想通过 SSH 隧道连接内网 PostgreSQL,已确认本机 psql 可以连接,但 DBX 中测试连接提示..." + validations: + required: true + + - type: textarea + id: context + attributes: + label: 环境信息 + description: 连接方式、特殊环境、已尝试的排查方式等。 + placeholder: | + 连接方式: + 补充环境: diff --git a/.github/scripts/augment-latest-json-jdbc-plugin.mjs b/.github/scripts/augment-latest-json-jdbc-plugin.mjs new file mode 100644 index 0000000..9a95298 --- /dev/null +++ b/.github/scripts/augment-latest-json-jdbc-plugin.mjs @@ -0,0 +1,31 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +export function augmentLatestJsonWithJdbcPlugin({ latestJson, jdbcVersion, protocolVersion, url }) { + const data = JSON.parse(latestJson); + data.jdbc_plugin = { + version: jdbcVersion, + protocol_version: Number(protocolVersion), + url, + }; + return `${JSON.stringify(data, null, 2)}\n`; +} + +function main() { + const [latestJsonPath, jdbcVersion, protocolVersion, url] = process.argv.slice(2); + if (!latestJsonPath || !jdbcVersion || !protocolVersion || !url) { + console.error("Usage: augment-latest-json-jdbc-plugin.mjs "); + process.exit(1); + } + + const updated = augmentLatestJsonWithJdbcPlugin({ + latestJson: readFileSync(latestJsonPath, "utf8"), + jdbcVersion, + protocolVersion, + url, + }); + writeFileSync(latestJsonPath, updated); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.github/scripts/bump-agent-versions.mjs b/.github/scripts/bump-agent-versions.mjs new file mode 100644 index 0000000..a61dd5d --- /dev/null +++ b/.github/scripts/bump-agent-versions.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; + +const VERSIONS_PATH = "agents/versions.json"; + +function bumpPatchVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version); + if (!match) { + throw new Error(`Agent version '${version}' is not a patchable semver version.`); + } + return `${match[1]}.${match[2]}.${Number(match[3]) + 1}${match[4]}`; +} + +function pathChanged(changedFiles, pathPrefix) { + const normalized = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`; + return changedFiles.some((file) => file === pathPrefix || file.startsWith(normalized)); +} + +function isCommonRuntimeChange(file) { + return file === "agents/common/build.gradle" || file.startsWith("agents/common/src/main/"); +} + +function parseLegacyStandaloneProjects(buildGradle) { + const match = /legacyStandaloneProjects\s*=\s*\[([^\]]*)\]/m.exec(buildGradle); + if (!match) return new Set(); + + return new Set( + [...match[1].matchAll(/['"]([^'"]+)['"]/g)] + .map((entry) => entry[1]) + .filter(Boolean), + ); +} + +function fileContainsCommonDependency(path, moduleExists, readModuleFile) { + if (!moduleExists(path)) return false; + const source = readModuleFile(path); + return /project\(\s*['"]:common['"]\s*\)/.test(source); +} + +function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) { + let checkDir = null; + if (moduleName === "oracle" && moduleExists("agents/drivers/oracle-go")) { + checkDir = "drivers/oracle-go"; + } else if (moduleExists(`agents/drivers/${moduleName}`)) { + checkDir = `drivers/${moduleName}`; + } else if (moduleExists(`agents/${moduleName}`) || moduleName === "common") { + checkDir = moduleName; + } + + if (!checkDir) return null; + + const modulePath = `agents/${checkDir}`; + const buildGradlePath = `${modulePath}/build.gradle`; + const hasBuildGradle = moduleExists(buildGradlePath); + const explicitlyDependsOnCommon = fileContainsCommonDependency(buildGradlePath, moduleExists, readModuleFile); + + return { + checkDir, + modulePath, + commonDependent: hasBuildGradle && (explicitlyDependsOnCommon || !legacyStandaloneModules.has(moduleName)), + }; +} + +export function evaluateAgentVersionBump({ + versions, + prevVersions = versions, + changedFiles, + legacyStandaloneModules = new Set(), + moduleExists = existsSync, + readModuleFile = (path) => readFileSync(path, "utf8"), + skipBump = false, + manualVersionsChanged = changedFiles.includes(VERSIONS_PATH), +}) { + const nextVersions = { ...versions }; + const logs = []; + let changed = false; + + if (manualVersionsChanged && !skipBump) { + logs.push("Manual agents/versions.json changes detected; preserving manually changed module versions and auto-bumping the rest."); + } + + if (skipBump) { + logs.push("Skipping automatic module version bump for migrated first release; versions.json was carried over from dbx-agents."); + return { changed, versions: nextVersions, prevVersions, logs }; + } + + const commonChanged = changedFiles.some(isCommonRuntimeChange); + if (commonChanged) { + logs.push("Common agent runtime changes detected; common-triggered bumps are limited to modules that package agents/common."); + } + + for (const moduleName of Object.keys(versions)) { + const module = resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }); + if (!module) continue; + + const moduleChanged = pathChanged(changedFiles, module.modulePath); + // Only modules that package agents/common need installer-visible updates + // for shared Java runtime changes; native and standalone agents do not. + const commonAffectsModule = commonChanged && module.commonDependent; + const oldVersion = nextVersions[moduleName] ?? "0.1.0"; + const prevVersion = prevVersions[moduleName] ?? ""; + const manuallyVersioned = manualVersionsChanged && (!prevVersion || prevVersion !== oldVersion); + + if (!moduleChanged && !commonAffectsModule) { + logs.push(` ${moduleName}: no changes`); + } else if (manuallyVersioned) { + if (!prevVersion) { + logs.push(` ${moduleName}: CHANGED, new module version kept at ${oldVersion}`); + } else { + logs.push(` ${moduleName}: CHANGED, manual version ${prevVersion} -> ${oldVersion}`); + } + } else { + const newVersion = bumpPatchVersion(oldVersion); + nextVersions[moduleName] = newVersion; + changed = true; + logs.push(` ${moduleName}: CHANGED`); + logs.push(` ${moduleName}: ${oldVersion} -> ${newVersion}`); + } + } + + return { changed, versions: nextVersions, prevVersions, logs }; +} + +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function parseArgs(argv) { + const options = { + migratedFirstRelease: false, + prevTag: "", + prevVersionsFile: "", + skipBump: false, + write: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--write") { + options.write = true; + } else if (arg === "--skip-bump") { + options.skipBump = true; + } else if (arg === "--prev-tag") { + options.prevTag = argv[++index] ?? ""; + } else if (arg === "--prev-versions-file") { + options.prevVersionsFile = argv[++index] ?? ""; + } else if (arg === "--migrated-first-release") { + options.migratedFirstRelease = (argv[++index] ?? "") === "true"; + } else { + throw new Error(`Unexpected argument: ${arg}`); + } + } + + if (!options.prevTag) { + throw new Error("--prev-tag is required."); + } + return options; +} + +function outputStepValues(result, prevTag, migratedFirstRelease) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) return; + + appendFileSync( + outputPath, + [ + `versions=${JSON.stringify(result.versions)}`, + `prev_versions=${JSON.stringify(result.prevVersions)}`, + `prev_tag=${prevTag}`, + `migrated_first_release=${migratedFirstRelease}`, + "", + ].join("\n"), + ); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const versions = JSON.parse(readFileSync(VERSIONS_PATH, "utf8")); + const legacyStandaloneModules = parseLegacyStandaloneProjects(readFileSync("agents/build.gradle", "utf8")); + const changedFiles = options.skipBump ? [] : git(["diff", "--name-only", `${options.prevTag}..HEAD`]).split("\n").filter(Boolean); + const manualVersionsChanged = changedFiles.includes(VERSIONS_PATH); + const prevVersions = options.prevVersionsFile + ? JSON.parse(readFileSync(options.prevVersionsFile, "utf8")) + : manualVersionsChanged + ? JSON.parse(git(["show", `${options.prevTag}:${VERSIONS_PATH}`])) + : versions; + + const result = evaluateAgentVersionBump({ + versions, + prevVersions, + changedFiles, + legacyStandaloneModules, + skipBump: options.skipBump, + manualVersionsChanged, + }); + + for (const line of result.logs) { + console.log(line); + } + + const versionsJson = `${JSON.stringify(result.versions, null, 2)}\n`; + if (options.write) { + writeFileSync(VERSIONS_PATH, versionsJson); + } + console.log(versionsJson); + outputStepValues(result, options.prevTag, options.migratedFirstRelease); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.github/scripts/bump-jdbc-plugin-version.mjs b/.github/scripts/bump-jdbc-plugin-version.mjs new file mode 100644 index 0000000..c072bd9 --- /dev/null +++ b/.github/scripts/bump-jdbc-plugin-version.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +const POM_PATH = "plugins/jdbc/pom.xml"; +const MANIFEST_PATH = "plugins/jdbc/manifest.json"; + +function firstProjectVersion(pomXml) { + const match = pomXml.match(/([^<]+)<\/version>/); + return match?.[1]?.trim() ?? ""; +} + +function manifestVersion(manifestJson) { + return JSON.parse(manifestJson).version ?? ""; +} + +function bumpPatchVersion(version) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/); + if (!match) { + throw new Error(`JDBC plugin version '${version}' is not a patchable semver version.`); + } + return `${match[1]}.${match[2]}.${Number(match[3]) + 1}${match[4]}`; +} + +function isReleaseBumpRelevantJdbcPluginChange(file) { + if (file.startsWith("plugins/jdbc/src/") || file.startsWith("plugins/jdbc/bin/")) return true; + if (!file.startsWith("plugins/jdbc/")) return false; + if (file.startsWith("plugins/jdbc/dist/") || file.startsWith("plugins/jdbc/target/")) return false; + if (file === "plugins/jdbc/README.md" || file === "plugins/jdbc/package.sh") return false; + if (file === POM_PATH || file === MANIFEST_PATH) return false; + return true; +} + +function hasJdbcPluginVersionChange(file) { + return file === POM_PATH || file === MANIFEST_PATH; +} + +function updatePomVersion(pomXml, version) { + return pomXml.replace(/()([^<]+)(<\/version>)/, `$1${version}$3`); +} + +function updateManifestVersion(manifestJson, version) { + const manifest = JSON.parse(manifestJson); + manifest.version = version; + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +export function evaluateJdbcPluginReleaseBump({ changedFiles, pomXml, manifestJson }) { + const pomVersion = firstProjectVersion(pomXml); + const currentManifestVersion = manifestVersion(manifestJson); + if (pomVersion !== currentManifestVersion) { + throw new Error(`JDBC plugin version mismatch: pom.xml is ${pomVersion} but manifest.json is ${currentManifestVersion}.`); + } + + const shouldBump = changedFiles.some(isReleaseBumpRelevantJdbcPluginChange) && !changedFiles.some(hasJdbcPluginVersionChange); + const newVersion = shouldBump ? bumpPatchVersion(pomVersion) : pomVersion; + return { + changed: shouldBump, + oldVersion: pomVersion, + newVersion, + pomXml: shouldBump ? updatePomVersion(pomXml, newVersion) : pomXml, + manifestJson: shouldBump ? updateManifestVersion(manifestJson, newVersion) : manifestJson, + }; +} + +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function main() { + const [baseRef = "HEAD~1", headRef = "HEAD", ...flags] = process.argv.slice(2); + const write = flags.includes("--write"); + const changedFiles = git(["diff", "--name-only", baseRef, headRef]).split("\n").filter(Boolean); + const result = evaluateJdbcPluginReleaseBump({ + changedFiles, + pomXml: readFileSync(POM_PATH, "utf8"), + manifestJson: readFileSync(MANIFEST_PATH, "utf8"), + }); + + if (write && result.changed) { + writeFileSync(POM_PATH, result.pomXml); + writeFileSync(MANIFEST_PATH, result.manifestJson); + } + + console.log(`changed=${result.changed}`); + console.log(`old_version=${result.oldVersion}`); + console.log(`new_version=${result.newVersion}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.github/scripts/check-jdbc-plugin-version.mjs b/.github/scripts/check-jdbc-plugin-version.mjs new file mode 100644 index 0000000..538b201 --- /dev/null +++ b/.github/scripts/check-jdbc-plugin-version.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; + +const POM_PATH = "plugins/jdbc/pom.xml"; +const MANIFEST_PATH = "plugins/jdbc/manifest.json"; + +function firstProjectVersion(pomXml) { + const match = pomXml.match(/([^<]+)<\/version>/); + return match?.[1]?.trim() ?? ""; +} + +function manifestVersion(manifestJson) { + return JSON.parse(manifestJson).version ?? ""; +} + +export function evaluateJdbcPluginVersionChange({ headPomVersion, headManifestVersion }) { + const errors = []; + if (headPomVersion !== headManifestVersion) { + errors.push(`JDBC plugin version mismatch: pom.xml is ${headPomVersion} but manifest.json is ${headManifestVersion}.`); + return errors; + } + return errors; +} + +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function readFileAt(ref, path) { + return git(["show", `${ref}:${path}`]); +} + +function main() { + const [, headRef = "HEAD"] = process.argv.slice(2); + const headPomVersion = firstProjectVersion(readFileAt(headRef, POM_PATH)); + const headManifestVersion = manifestVersion(readFileAt(headRef, MANIFEST_PATH)); + const errors = evaluateJdbcPluginVersionChange({ + headPomVersion, + headManifestVersion, + }); + + if (errors.length) { + for (const error of errors) { + console.error(`::error::${error}`); + } + process.exit(1); + } + console.log(`JDBC plugin version check passed (${headPomVersion}).`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.github/scripts/i18n-autofill.mjs b/.github/scripts/i18n-autofill.mjs new file mode 100644 index 0000000..9ec2e4c --- /dev/null +++ b/.github/scripts/i18n-autofill.mjs @@ -0,0 +1,544 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const LOCALES_DIR = "apps/desktop/src/i18n/locales"; +const SOURCE_LOCALE = "zh-CN"; +const TARGET_LOCALES = ["en", "es", "it", "ja", "pt-BR", "zh-TW"]; +const TARGET_LABELS = { + en: "English", + es: "Spanish", + it: "Italian", + ja: "Japanese", + "pt-BR": "Brazilian Portuguese", + "zh-TW": "Traditional Chinese used in Taiwan", +}; + +const args = new Set(process.argv.slice(2)); +const dryRun = args.has("--dry-run") || !args.has("--write"); +const mockTranslations = args.has("--mock-translations") || process.env.I18N_MOCK_TRANSLATIONS === "1"; +const baseRef = valueArg("--base-ref") || process.env.I18N_BASE_REF || "origin/main"; +const apiKey = process.env.DEEPSEEK_API_KEY || ""; +const apiUrl = process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions"; +const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro"; + +async function main() { + const sourcePath = localePath(SOURCE_LOCALE); + const baseSourceText = readBaseFile(sourcePath); + const headSource = parseLocaleFile(readFileSync(sourcePath, "utf8"), sourcePath); + const baseSource = parseLocaleFile(baseSourceText, `${baseRef}:${sourcePath}`); + + // Only PR-new Chinese source keys are eligible for auto-fill; historical + // locale gaps intentionally stay untouched to keep bot patches scoped. + const baseLeaves = flattenLeaves(baseSource.root); + const headLeaves = flattenLeaves(headSource.root); + const newSourceKeys = [...headLeaves.keys()].filter((key) => !baseLeaves.has(key)); + + if (newSourceKeys.length === 0) { + console.log(`No new ${SOURCE_LOCALE} i18n keys compared with ${baseRef}`); + return; + } + + console.log(`Found ${newSourceKeys.length} new ${SOURCE_LOCALE} i18n key(s):`); + for (const key of newSourceKeys) console.log(`- ${key}`); + + let patchedFiles = 0; + const summary = { + sourceLocale: SOURCE_LOCALE, + newKeys: newSourceKeys, + updatedLocales: [], + }; + + for (const locale of TARGET_LOCALES) { + const path = localePath(locale); + if (!existsSync(path)) throw new Error(`Missing locale file: ${path}`); + + const text = readFileSync(path, "utf8"); + const parsed = parseLocaleFile(text, path); + const targetLeaves = flattenLeaves(parsed.root); + const missingKeys = newSourceKeys.filter((key) => !targetLeaves.has(key)); + + if (missingKeys.length === 0) { + console.log(`${locale}: all new keys already present`); + continue; + } + + console.log(`${locale}: missing ${missingKeys.length} new key(s)`); + for (const key of missingKeys) console.log(` - ${key}`); + + const translations = await translateMissing(locale, missingKeys, headLeaves); + const updated = patchLocaleText(text, parsed, missingKeys, translations, headSource.root); + + if (updated === text) { + throw new Error(`${path}: expected changes for missing keys, but patch was empty`); + } + + parseLocaleFile(updated, path); + if (!dryRun) writeFileSync(path, updated); + patchedFiles += 1; + summary.updatedLocales.push({ locale, count: missingKeys.length }); + } + + if (patchedFiles === 0) { + console.log("No locale files needed changes"); + } else if (dryRun) { + console.log(`Dry run completed; ${patchedFiles} locale file(s) would be patched`); + } else { + console.log(`Patched ${patchedFiles} locale file(s)`); + } + + writeSummary(summary); +} + +function valueArg(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function localePath(locale) { + return join(LOCALES_DIR, `${locale}.ts`); +} + +function readBaseFile(path) { + try { + return execFileSync("git", ["show", `${baseRef}:${path}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + } catch (error) { + throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`); + } +} + +function writeSummary(summary) { + const path = process.env.I18N_SUMMARY_FILE; + if (!path) return; + writeFileSync(path, `${JSON.stringify(summary, null, 2)}\n`); +} + +async function translateMissing(locale, keys, sourceLeaves) { + if (mockTranslations) { + return Object.fromEntries(keys.map((key) => [key, `[${locale}] ${sourceLeaves.get(key)}`])); + } + + if (!apiKey) { + throw new Error(`DEEPSEEK_API_KEY is required to translate ${keys.length} missing ${locale} i18n key(s)`); + } + + const items = keys.map((key) => ({ key, source: sourceLeaves.get(key) })); + const response = await fetch(apiUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + response_format: { type: "json_object" }, + temperature: 0.1, + max_tokens: Math.max(1024, keys.length * 120), + messages: [ + { + role: "system", + content: [ + `Translate DBX UI i18n strings from Simplified Chinese to ${TARGET_LABELS[locale]}.`, + 'Return strict JSON only in this exact shape: {"translations":{"path.to.key":"translated text"}}.', + "Keep i18n placeholders such as {count}, {message}, and {name} exactly unchanged.", + "Keep product names, database names, SQL keywords, file extensions, shortcuts, and code-like terms unchanged unless the target language convention clearly translates them.", + "Do not add keys, remove keys, or translate key names.", + ].join(" "), + }, + { + role: "user", + content: JSON.stringify({ targetLocale: locale, items }), + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`DeepSeek API failed for ${locale}: HTTP ${response.status} ${await response.text()}`); + } + + const data = await response.json(); + const content = data?.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content.trim()) { + throw new Error(`DeepSeek API returned no translation content for ${locale}`); + } + + const parsed = parseJsonContent(content); + const translations = parsed.translations; + if (!translations || typeof translations !== "object" || Array.isArray(translations)) { + throw new Error(`DeepSeek response for ${locale} must contain a translations object`); + } + + for (const key of keys) { + if (typeof translations[key] !== "string" || !translations[key].trim()) { + throw new Error(`DeepSeek response for ${locale} is missing translation for ${key}`); + } + assertSamePlaceholders(key, sourceLeaves.get(key), translations[key], locale); + } + + return translations; +} + +function parseJsonContent(content) { + const trimmed = content.trim(); + const withoutFence = trimmed + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + try { + return JSON.parse(withoutFence); + } catch (error) { + throw new Error(`Unable to parse DeepSeek JSON response: ${error.message}\n${content}`); + } +} + +function assertSamePlaceholders(key, source, translated, locale) { + const sourcePlaceholders = placeholders(source); + const translatedPlaceholders = placeholders(translated); + if (sourcePlaceholders.join("\0") !== translatedPlaceholders.join("\0")) { + throw new Error( + `${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`, + ); + } +} + +function placeholders(value) { + return [...String(value).matchAll(/\{[^{}]+\}/g)].map((match) => match[0]).sort(); +} + +function patchLocaleText(text, parsed, missingKeys, translations, sourceRoot) { + const groups = groupMissingKeys(parsed.root, missingKeys, translations, sourceRoot); + const edits = []; + + for (const group of groups) { + const insertText = buildInsertion(text, group.objectNode, group.entries, group.sourceObjectNode); + edits.push({ index: lineStartBefore(text, group.objectNode.closeBrace), text: insertText }); + } + + return applyEdits(text, edits); +} + +function groupMissingKeys(targetRoot, missingKeys, translations, sourceRoot) { + const byParent = new Map(); + + for (const key of missingKeys) { + const segments = key.split("."); + const { node: parentNode, path: parentPath, remaining } = findInsertionParent(targetRoot, segments); + const parentKey = parentPath.join("."); + const sourceParent = findObjectNode(sourceRoot, parentPath); + if (!sourceParent) throw new Error(`${key}: insertion parent is missing in ${SOURCE_LOCALE}`); + + const sourceOrder = sourceParent.properties.map((property) => property.key); + const entry = { + path: remaining, + value: translations[key], + order: sourceOrder.indexOf(remaining[0]), + }; + + if (!byParent.has(parentKey)) byParent.set(parentKey, { objectNode: parentNode, sourceObjectNode: sourceParent, entries: [] }); + byParent.get(parentKey).entries.push(entry); + } + + return [...byParent.values()].map((group) => ({ + ...group, + entries: group.entries.sort((left, right) => left.order - right.order || left.path.join(".").localeCompare(right.path.join("."))), + })); +} + +function findInsertionParent(root, fullPath) { + let current = root; + const existingPath = []; + + for (const segment of fullPath.slice(0, -1)) { + const property = current.properties.find((candidate) => candidate.key === segment); + if (!property) break; + if (property.value.type !== "object") { + throw new Error(`${fullPath.join(".")}: cannot insert under non-object locale key ${[...existingPath, segment].join(".")}`); + } + current = property.value; + existingPath.push(segment); + } + + return { node: current, path: existingPath, remaining: fullPath.slice(existingPath.length) }; +} + +function findObjectNode(root, path) { + let current = root; + for (const segment of path) { + const property = current.properties.find((candidate) => candidate.key === segment); + if (!property || property.value.type !== "object") return null; + current = property.value; + } + return current; +} + +function buildInsertion(text, objectNode, entries, sourceObjectNode) { + const parentIndent = lineIndentBefore(text, objectNode.closeBrace); + const childIndent = objectNode.properties.length > 0 ? lineIndentBefore(text, objectNode.properties[0].start) : `${parentIndent} `; + const lines = renderEntryTree(buildEntryTree(entries), sourceObjectNode, childIndent); + + return `${lines.join("\n")}\n`; +} + +function buildEntryTree(entries) { + const root = { children: new Map(), value: undefined }; + for (const entry of entries) { + let current = root; + for (const segment of entry.path) { + if (!current.children.has(segment)) current.children.set(segment, { children: new Map(), value: undefined }); + current = current.children.get(segment); + } + current.value = entry.value; + } + return root; +} + +function renderEntryTree(tree, sourceObjectNode, indent) { + const lines = []; + const entries = [...tree.children.entries()].sort(([left], [right]) => compareSourceOrder(sourceObjectNode, left, right)); + + for (const [key, child] of entries) { + if (child.children.size === 0) { + lines.push(`${indent}${formatKey(key)}: ${JSON.stringify(child.value)},`); + continue; + } + + const childSourceObject = sourceObjectNode?.properties.find((property) => property.key === key && property.value.type === "object")?.value; + lines.push(`${indent}${formatKey(key)}: {`); + lines.push(...renderEntryTree(child, childSourceObject, `${indent} `)); + lines.push(`${indent}},`); + } + + return lines; +} + +function compareSourceOrder(sourceObjectNode, left, right) { + const order = sourceObjectNode?.properties.map((property) => property.key) || []; + const leftIndex = order.indexOf(left); + const rightIndex = order.indexOf(right); + if (leftIndex !== -1 || rightIndex !== -1) { + if (leftIndex === -1) return 1; + if (rightIndex === -1) return -1; + return leftIndex - rightIndex; + } + return left.localeCompare(right); +} + +function formatKey(key) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key); +} + +function lineIndentBefore(text, index) { + const lineStart = lineStartBefore(text, index); + const match = text.slice(lineStart, index).match(/^[ \t]*/); + return match ? match[0] : ""; +} + +function lineStartBefore(text, index) { + return text.lastIndexOf("\n", Math.max(0, index - 1)) + 1; +} + +function applyEdits(text, edits) { + let updated = text; + for (const edit of edits.sort((left, right) => right.index - left.index)) { + updated = `${updated.slice(0, edit.index)}${edit.text}${updated.slice(edit.index)}`; + } + return updated; +} + +function flattenLeaves(root) { + const result = new Map(); + flattenNode(root, [], result); + return result; +} + +function flattenNode(node, path, result) { + for (const property of node.properties) { + const nextPath = [...path, property.key]; + if (property.value.type === "object") { + flattenNode(property.value, nextPath, result); + } else { + result.set(nextPath.join("."), property.value.value); + } + } +} + +function parseLocaleFile(text, file) { + const parser = new Parser(text, file); + const root = parser.parseLocaleRoot(); + return { root }; +} + +class Parser { + constructor(text, file) { + this.text = text; + this.file = file; + this.index = 0; + } + + parseLocaleRoot() { + const exportIndex = this.text.indexOf("export default"); + if (exportIndex === -1) this.fail("Missing export default"); + this.index = exportIndex + "export default".length; + this.skipSpace(); + + if (this.peek() === "{") return this.parseObject(); + + const callee = this.parseIdentifier(); + this.skipSpace(); + if (!callee || this.peek() !== "(") this.fail("Expected object literal or wrapper call after export default"); + this.index += 1; + this.skipSpace(); + const object = this.parseObject(); + this.skipSpace(); + if (this.peek() !== ")") this.fail("Expected closing wrapper call parenthesis"); + return object; + } + + parseObject() { + this.expect("{"); + const openBrace = this.index - 1; + const properties = []; + + while (true) { + this.skipSpace(); + if (this.peek() === "}") { + const closeBrace = this.index; + this.index += 1; + return { type: "object", openBrace, closeBrace, end: this.index, properties }; + } + + const start = this.index; + const key = this.parseKey(); + this.skipSpace(); + this.expect(":"); + this.skipSpace(); + const value = this.parseValue(); + this.skipSpace(); + + let hasComma = false; + if (this.peek() === ",") { + hasComma = true; + this.index += 1; + } + + properties.push({ key, start, end: this.index, hasComma, value }); + } + } + + parseValue() { + const char = this.peek(); + if (char === "{") return this.parseObject(); + if (char === '"' || char === "'") { + const start = this.index; + const value = this.parseString(); + return { type: "string", start, end: this.index, value }; + } + this.fail(`Unsupported locale value starting with ${JSON.stringify(char)}`); + } + + parseKey() { + const char = this.peek(); + if (char === '"' || char === "'") return this.parseString(); + const key = this.parseIdentifier(); + if (!key) this.fail("Expected property key"); + return key; + } + + parseIdentifier() { + const match = /^[A-Za-z_$][\w$-]*/.exec(this.text.slice(this.index)); + if (!match) return ""; + this.index += match[0].length; + return match[0]; + } + + parseString() { + const quote = this.peek(); + this.index += 1; + let value = ""; + while (this.index < this.text.length) { + const char = this.text[this.index]; + if (char === "\\") { + const escaped = this.text[this.index + 1]; + if (!escaped) this.fail("Unterminated string literal"); + this.index += 2; + if (escaped === "n") value += "\n"; + else if (escaped === "r") value += "\r"; + else if (escaped === "t") value += "\t"; + else if (escaped === "b") value += "\b"; + else if (escaped === "f") value += "\f"; + else if (escaped === "v") value += "\v"; + else if (escaped === "0") value += "\0"; + else if (escaped === "u") { + const hex = this.text.slice(this.index, this.index + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) this.fail("Invalid unicode escape"); + value += String.fromCharCode(Number.parseInt(hex, 16)); + this.index += 4; + } else { + value += escaped; + } + continue; + } + if (char === quote) { + this.index += 1; + return value; + } + if (char === "\n" || char === "\r") this.fail("Unterminated string literal"); + value += char; + this.index += 1; + } + this.fail("Unterminated string literal"); + } + + skipSpace() { + while (this.index < this.text.length) { + const char = this.text[this.index]; + if (/\s/.test(char)) { + this.index += 1; + continue; + } + if (this.text.startsWith("//", this.index)) { + const next = this.text.indexOf("\n", this.index + 2); + this.index = next === -1 ? this.text.length : next + 1; + continue; + } + if (this.text.startsWith("/*", this.index)) { + const next = this.text.indexOf("*/", this.index + 2); + if (next === -1) this.fail("Unterminated block comment"); + this.index = next + 2; + continue; + } + break; + } + } + + expect(expected) { + if (this.peek() !== expected) this.fail(`Expected ${expected}`); + this.index += expected.length; + } + + peek() { + return this.text[this.index] || ""; + } + + fail(message) { + const location = this.location(); + throw new Error(`${this.file}:${location.line}:${location.column}: ${message}`); + } + + location() { + const before = this.text.slice(0, this.index); + const lines = before.split(/\r?\n/); + return { line: lines.length, column: lines[lines.length - 1].length + 1 }; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/.github/scripts/issue-claim.mjs b/.github/scripts/issue-claim.mjs new file mode 100644 index 0000000..7e8b9ad --- /dev/null +++ b/.github/scripts/issue-claim.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const issueNumber = process.env.ISSUE_NUMBER; +const commentBody = process.env.COMMENT_BODY || ""; +const commentUser = process.env.COMMENT_USER || ""; +const commentUserType = process.env.COMMENT_USER_TYPE || ""; + +// Only handle /claim as a standalone word +if (!/(?:^|\s)\/claim(?:\s|$)/.test(commentBody)) { + process.exit(0); +} + +// Don't let bots claim +if (commentUserType === "Bot") { + console.log("Commenter is a bot, skipping"); + process.exit(0); +} + +console.log(`/claim from @${commentUser} on #${issueNumber}`); + +async function gh(args) { + const { stdout } = await execFileAsync("gh", args, { maxBuffer: 1024 * 1024 }); + return stdout.trim(); +} + +async function ghJson(args) { + const out = await gh(args); + return JSON.parse(out); +} + +// Check current assignees +const assignees = await ghJson([ + "issue", "view", issueNumber, + "--json", "assignees", + "-q", ".assignees", +]); + +if (assignees.length > 0) { + const names = assignees.map((a) => `@${a.login}`).join(", "); + await gh([ + "issue", "comment", issueNumber, + "--body", `❌ @${commentUser} 这个 issue 已经有人认领了:${names}`, + ]); + process.exit(0); +} + +// Assign +await gh(["issue", "edit", issueNumber, "--add-assignee", commentUser]); + +// Confirm +await gh([ + "issue", "comment", issueNumber, + "--body", `✅ @${commentUser} 已认领 #${issueNumber},开始处理吧!`, +]); + +console.log(`Assigned @${commentUser} to #${issueNumber}`); diff --git a/.github/scripts/label-issue-database.mjs b/.github/scripts/label-issue-database.mjs new file mode 100644 index 0000000..2187cd3 --- /dev/null +++ b/.github/scripts/label-issue-database.mjs @@ -0,0 +1,570 @@ +#!/usr/bin/env node +import fs from "node:fs"; + +const LABEL_PREFIX = "db/"; +const USER_PRIORITY_PREFIX = "user-priority/"; +const API_VERSION = "2022-11-28"; +const dryRun = process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; + +const USER_PRIORITY_LABELS = { + P0: { color: "b60205", description: "User-reported priority: urgent" }, + P1: { color: "d93f0b", description: "User-reported priority: important" }, + P2: { color: "fbca04", description: "User-reported priority: normal" }, + P3: { color: "bfd4f2", description: "User-reported priority: low" }, +}; + +const LABEL_PALETTE = [ + "0e8a16", + "1d76db", + "5319e7", + "c2e0c6", + "bfd4f2", + "d4c5f9", + "fef2c0", + "fbca04", + "f9d0c4", + "e99695", + "f29513", + "c5def5", +]; + +const extraAliases = { + mysql: ["mariadb", "percona", "tidb"], + postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres"], + sqlite: ["sqlite3", "sql lite"], + clickhouse: ["click house", "ch"], + sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"], + mongodb: ["mongo", "mongodb"], + oracle: ["oracle database"], + elasticsearch: ["elastic search"], + chromadb: ["chroma"], + manticoresearch: ["manticore"], + dameng: ["dm8", "dameng", "达梦"], + kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓"], + highgo: ["瀚高"], + yashandb: ["yashan", "崖山"], + saphana: ["hana", "sap hana"], + opengauss: ["open gauss"], + "oceanbase-oracle": ["oceanbase", "oceanbase oracle"], + gbase: ["gbase 8a", "gbase8a", "gbase 8s", "gbase8s"], + access: ["microsoft access", "ms access"], + vastbase: ["vastbase g", "vastbaseg"], + prestosql: ["presto", "presto sql"], + hive: ["apache hive"], + db2: ["ibm db2"], + informix: ["ibm informix"], + bigquery: ["google bigquery"], + kylin: ["apache kylin"], + oscar: ["shentong", "oscar", "神通"], + xugu: ["xugudb", "xugu", "虚谷"], + zookeeper: ["zoo keeper", "apache zookeeper"], + mq: ["message queue", "kafka", "rabbitmq", "rocketmq"], + iotdb: ["apache iotdb"], + iris: ["intersystems iris", "intersystems cache", "intersystems caché", "cache", "caché", "ensemble"], + spark: ["apache spark"], +}; + +const globallyAmbiguousAliases = new Set([ + "access", + "ch", + "h2", + "iris", + "jdbc", + "mq", + "pg", + "spark", +]); + +const genericDatabaseValues = [ + "general", + "generic", + "global", + "all", + "any", + "none", + "n/a", + "na", + "通用", + "全部", + "所有", + "无", + "不适用", +]; + +const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url); +const manifest = JSON.parse(fs.readFileSync(manifestUrl, "utf8")); +const drivers = manifest.drivers.map((driver) => ({ + dbType: driver.dbType, + label: driver.label, + aliases: [...new Set([driver.dbType, driver.label, ...(extraAliases[driver.dbType] || [])])], +})); +const exactAsciiAliases = new Set( + drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)), +); + +function loadIssue() { + if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) { + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + return event.issue || {}; + } + + return { + number: process.env.ISSUE_NUMBER, + title: process.env.ISSUE_TITLE || "", + body: process.env.ISSUE_BODY || "", + labels: process.env.ISSUE_LABELS ? JSON.parse(process.env.ISSUE_LABELS) : [], + }; +} + +function labelNames(labels) { + return (labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean); +} + +function stablePaletteColor(value) { + let hash = 0; + for (const char of value) { + hash = (hash * 31 + char.codePointAt(0)) >>> 0; + } + return LABEL_PALETTE[hash % LABEL_PALETTE.length]; +} + +function extractIssueFormSection(body, predicate) { + const matches = [...String(body || "").matchAll(/^###\s+(.+?)\s*$/gm)]; + for (const [index, match] of matches.entries()) { + const heading = match[1].trim(); + if (!predicate(heading)) continue; + + const start = match.index + match[0].length; + const end = matches[index + 1]?.index ?? body.length; + return body.slice(start, end).trim(); + } + + return ""; +} + +function extractDatabaseField(body) { + return extractIssueFormSection(body, (heading) => ( + heading.includes("数据库类型") || + heading.toLowerCase().includes("database type") || + /^database$/i.test(heading) + )); +} + +function extractPriorityField(body) { + return extractIssueFormSection(body, (heading) => ( + heading.includes("优先级") || + heading.toLowerCase().includes("priority") + )); +} + +function extractTitleSummaryField(body) { + const headingPredicates = [ + (heading) => heading.includes("问题描述") || heading.toLowerCase().includes("description"), + (heading) => heading.includes("当前痛点") || heading.toLowerCase().includes("problem"), + (heading) => heading.includes("期望方案") || heading.toLowerCase().includes("proposal"), + (heading) => heading === "描述" || heading.toLowerCase() === "description", + ]; + + for (const predicate of headingPredicates) { + const section = extractIssueFormSection(body, predicate); + if (section) return section; + } + + return ""; +} + +function matchPriorityLabel(value) { + const match = String(value || "").normalize("NFKC").match(/\bP([0-3])\b/i); + return match ? `P${match[1]}` : null; +} + +function genericIssueTitlePrefix(title) { + const normalized = String(title || "").normalize("NFKC").trim().replace(/\s+/g, " "); + const bracketMatch = normalized.match(/^([\[【][^\]】]*(?:bug|feature|feat|compatibility|question)[^\]】]*[\]】])$/i); + if (bracketMatch) return bracketMatch[1]; + + const bareMatch = normalized.match(/^(bug|feature|feat|compatibility|question):?$/i); + if (!bareMatch) return null; + + const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : ( + bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase() + ); + return `[${normalizedPrefix}]`; +} + +function cleanTitleSummaryLine(line) { + return String(line || "") + .replace(/]*>/gi, "") + .replace(/<[^>]+>/g, "") + .replace(/^>\s*/, "") + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)、]\s*/, "") + .replace(/!\[[^\]]*?\]\([^)]*?\)/g, "") + .replace(/\[[^\]]*?\]\([^)]*?\)/g, "") + .replace(/[`*_~]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function isSkippableTitleSummaryLine(line) { + return ( + /^DBX debug log$/i.test(line) || + /^(Exported|User agent|Platform|Timezone):/i.test(line) || + /^(Native|Tauri) log dir:/i.test(line) || + /^=+.*logs?.*=+$/i.test(line) || + /^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) || + /^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line) + ); +} + +function firstSentence(value) { + const withoutCodeBlocks = String(value || "").replace(/```[\s\S]*?```/g, "\n"); + const lines = withoutCodeBlocks + .split(/\r?\n/) + .map(cleanTitleSummaryLine) + .filter((line) => !isSkippableTitleSummaryLine(line)) + .filter((line) => /[\p{L}\p{N}\u3400-\u9fff]/u.test(line)); + + for (const line of lines) { + const sentenceMatch = line.match(/^(.{4,120}?[。!?!?]|.{12,120}?\.(?=\s|$))/u); + const sentence = (sentenceMatch ? sentenceMatch[1] : line) + .replace(/[。!?!?.]+$/u, "") + .trim(); + if (sentence.length >= 4) { + return sentence.length > 90 ? `${sentence.slice(0, 89)}…` : sentence; + } + } + + return ""; +} + +function inferIssueTitle(title, body) { + const prefix = genericIssueTitlePrefix(title); + if (!prefix) return null; + + const summary = firstSentence(extractTitleSummaryField(body)); + if (!summary) return null; + + return `${prefix} ${summary}`; +} + +function normalizeText(value) { + return String(value || "") + .normalize("NFKC") + .toLowerCase() + .replace(/[·,。;:、]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function containsCjk(value) { + return /[\u3400-\u9fff]/u.test(value); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function compactAscii(value) { + return normalizeText(value).replace(/[^a-z0-9]/g, ""); +} + +function levenshteinDistance(left, right) { + if (left === right) return 0; + if (!left) return right.length; + if (!right) return left.length; + + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) { + const current = [leftIndex + 1]; + for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) { + const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1; + current[rightIndex + 1] = Math.min( + current[rightIndex] + 1, + previous[rightIndex + 1] + 1, + previous[rightIndex] + substitutionCost, + ); + } + previous = current; + } + + return previous[right.length]; +} + +function fuzzyThreshold(length) { + if (length < 6) return 0; + if (length <= 8) return 1; + return 2; +} + +function candidateTokens(text) { + const normalized = normalizeText(text); + const tokens = new Set(); + + for (const match of normalized.matchAll(/[a-z][a-z0-9._-]*/g)) { + const compact = compactAscii(match[0]); + if (!compact) continue; + + tokens.add(compact); + + // User reports often write database names directly followed by versions, + // for example mysql5.7, Oracle19c, or postgresql18. + const withoutVersion = compact.replace(/(?:v?\d[\da-z.]*)$/i, ""); + if (withoutVersion && withoutVersion !== compact) tokens.add(withoutVersion); + } + + return [...tokens].filter((token) => token.length >= 5); +} + +function fuzzyAliasMatches(text, alias) { + const normalizedAlias = normalizeText(alias); + if (containsCjk(normalizedAlias)) return false; + if (globallyAmbiguousAliases.has(normalizedAlias)) return false; + + const compactAlias = compactAscii(normalizedAlias); + const threshold = fuzzyThreshold(compactAlias.length); + if (!threshold) return false; + + return candidateTokens(text).some((token) => { + if (token !== compactAlias && exactAsciiAliases.has(token)) return false; + if (Math.abs(token.length - compactAlias.length) > threshold) return false; + return levenshteinDistance(token, compactAlias) <= threshold; + }); +} + +function aliasMatches(text, alias) { + const normalizedText = normalizeText(text); + const normalizedAlias = normalizeText(alias); + if (!normalizedAlias) return false; + + if (containsCjk(normalizedAlias)) { + return normalizedText.includes(normalizedAlias); + } + + const flexibleAlias = normalizedAlias + .split(/[\s._/-]+/) + .filter(Boolean) + .map(escapeRegExp) + .join("[\\s._/-]*"); + const trailingBoundary = /[0-9]$/.test(normalizedAlias) + ? "(?:[^a-z0-9]|$)" + : "(?:[^a-z0-9]|$|(?=v?\\d))"; + const pattern = new RegExp(`(^|[^a-z0-9])${flexibleAlias}${trailingBoundary}`, "i"); + return pattern.test(normalizedText); +} + +function isGenericDatabaseValue(value) { + const normalized = normalizeText(value); + const parts = normalized + .split(/[,\n/|;,、]+/) + .map((part) => part.trim()) + .filter(Boolean); + + return ( + parts.length > 0 && + parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic))) + ); +} + +function matchDrivers(text, { allowAmbiguous }) { + const matches = []; + + for (const driver of drivers) { + const matchedAlias = driver.aliases.find((alias) => { + const normalizedAlias = normalizeText(alias); + if (!allowAmbiguous && globallyAmbiguousAliases.has(normalizedAlias)) return false; + return aliasMatches(text, alias) || fuzzyAliasMatches(text, alias); + }); + + if (matchedAlias) { + matches.push({ ...driver, matchedAlias }); + } + } + + return matches; +} + +function uniqueByDbType(matches) { + const seen = new Set(); + return matches.filter((match) => { + if (seen.has(match.dbType)) return false; + seen.add(match.dbType); + return true; + }); +} + +async function githubRequest(method, path, body) { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + if (!token) throw new Error("GITHUB_TOKEN is required"); + if (!repository) throw new Error("GITHUB_REPOSITORY is required"); + + const response = await fetch(`https://api.github.com/repos/${repository}${path}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": API_VERSION, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + if (!response.ok) { + const message = payload?.message || response.statusText; + const error = new Error(`${method} ${path} failed: ${response.status} ${message}`); + error.status = response.status; + error.payload = payload; + throw error; + } + return payload; +} + +async function ensureLabel(name, description, color) { + try { + const dbType = name.startsWith(LABEL_PREFIX) ? name.slice(LABEL_PREFIX.length) : name; + await githubRequest("POST", "/labels", { name, color: color || stablePaletteColor(dbType), description }); + console.log(`Created label ${name}`); + } catch (error) { + // GitHub returns 422 when the label already exists; that is the common path + // after the first issue for a database type has been triaged. + if (error.status === 422) { + console.log(`Label ${name} already exists`); + return; + } + throw error; + } +} + +async function addLabels(issueNumber, names) { + await githubRequest("POST", `/issues/${issueNumber}/labels`, { labels: names }); +} + +async function removeLabel(issueNumber, name) { + try { + await githubRequest("DELETE", `/issues/${issueNumber}/labels/${encodeURIComponent(name)}`); + console.log(`Removed stale label ${name}`); + } catch (error) { + if (error.status === 404) return; + throw error; + } +} + +async function updateIssueTitle(issueNumber, title) { + await githubRequest("PATCH", `/issues/${issueNumber}`, { title }); +} + +const issue = loadIssue(); +if (issue.pull_request) { + console.log("Skipping pull request event"); + process.exit(0); +} + +if (!issue.number) { + throw new Error("Issue number is required"); +} + +const databaseField = extractDatabaseField(issue.body || ""); +const hasDatabaseField = databaseField.length > 0; +const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField); +// Free-form bodies often mention comparison databases or examples; the title +// and issue-form database field are the reliable labeling sources. +const sourceText = hasDatabaseField ? databaseField : issue.title || ""; +const matchedDrivers = isGenericDatabaseValue(sourceText) + ? [] + : uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField })); +const titleMatchedDrivers = uniqueByDbType(matchDrivers(issue.title || "", { allowAmbiguous: false })); +if (hasDatabaseField && titleMatchedDrivers.length > 0) { + matchedDrivers.push(...titleMatchedDrivers); +} +if (hasDatabaseField && matchedDrivers.length === 0 && !fieldIsGeneric) { + // Some issue forms contain only a version in the database field and put the + // actual database name in the title; keep that fallback conservative. + matchedDrivers.push( + ...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })), + ); +} +const uniqueMatchedDrivers = uniqueByDbType(matchedDrivers); +const targetLabels = uniqueMatchedDrivers.map((driver) => `${LABEL_PREFIX}${driver.dbType}`); +const targetLabelColors = Object.fromEntries( + uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]), +); +const priorityField = extractPriorityField(issue.body || ""); +const targetPriorityValue = matchPriorityLabel(priorityField); +const targetPriorityLabel = targetPriorityValue ? `${USER_PRIORITY_PREFIX}${targetPriorityValue}` : null; +if (targetPriorityValue) { + targetLabelColors[targetPriorityLabel] = USER_PRIORITY_LABELS[targetPriorityValue].color; +} +const existingLabels = labelNames(issue.labels); +const titleToUpdate = inferIssueTitle(issue.title || "", issue.body || ""); +const existingDatabaseLabels = existingLabels.filter((name) => name.startsWith(LABEL_PREFIX)); +const staleDatabaseLabels = hasDatabaseField + ? existingDatabaseLabels.filter((name) => !targetLabels.includes(name)) + : []; +const existingPriorityLabels = existingLabels.filter((name) => name.startsWith(USER_PRIORITY_PREFIX)); +const stalePriorityLabels = targetPriorityLabel + ? existingPriorityLabels.filter((name) => name !== targetPriorityLabel) + : []; +const labelsToAdd = [...targetLabels, targetPriorityLabel].filter(Boolean).filter((name) => !existingLabels.includes(name)); +const labelSpecs = [ + ...uniqueMatchedDrivers.map((driver) => ({ + name: `${LABEL_PREFIX}${driver.dbType}`, + description: `Database: ${driver.label}`, + color: stablePaletteColor(driver.dbType), + })), + ...(targetPriorityValue + ? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }] + : []), +]; + +console.log( + JSON.stringify( + { + issue: issue.number, + databaseField: hasDatabaseField ? databaseField : null, + titleMatched: titleMatchedDrivers.map(({ dbType, label, matchedAlias }) => ({ dbType, label, matchedAlias })), + matched: uniqueMatchedDrivers.map(({ dbType, label, matchedAlias }) => ({ dbType, label, matchedAlias })), + priorityField: priorityField || null, + priorityLabel: targetPriorityLabel, + priorityValue: targetPriorityValue, + titleToUpdate, + labelsToAdd, + targetLabelColors, + staleDatabaseLabels, + stalePriorityLabels, + }, + null, + 2, + ), +); + +if (dryRun) { + console.log("Dry run enabled; no GitHub API calls were made"); + process.exit(0); +} + +for (const labelSpec of labelSpecs) { + await ensureLabel(labelSpec.name, labelSpec.description, labelSpec.color); +} + +if (labelsToAdd.length > 0) { + await addLabels(issue.number, labelsToAdd); + console.log(`Added labels: ${labelsToAdd.join(", ")}`); +} else { + console.log("No database labels to add"); +} + +for (const label of staleDatabaseLabels) { + await removeLabel(issue.number, label); +} + +for (const label of stalePriorityLabels) { + await removeLabel(issue.number, label); +} + +if (titleToUpdate) { + await updateIssueTitle(issue.number, titleToUpdate); + console.log(`Updated title: ${titleToUpdate}`); +} diff --git a/.github/scripts/sync-atomgit-release.mjs b/.github/scripts/sync-atomgit-release.mjs new file mode 100644 index 0000000..e7be13e --- /dev/null +++ b/.github/scripts/sync-atomgit-release.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +import { basename, join } from "node:path"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { spawn } from "node:child_process"; + +const DEFAULT_API_BASE = "https://api.atomgit.com/api/v5"; +const DEFAULT_REPOSITORY = "t8y2/dbx"; +const DEFAULT_CONCURRENCY = 3; + +function parseArgs(argv) { + const args = { + apiBase: process.env.ATOMGIT_API_BASE || DEFAULT_API_BASE, + repository: process.env.ATOMGIT_REPOSITORY || DEFAULT_REPOSITORY, + token: process.env.ATOMGIT_TOKEN || "", + githubReleasePath: "", + assetsDir: "", + skipExistingAssets: true, + concurrency: Number.parseInt(process.env.ATOMGIT_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10), + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--github-release") { + args.githubReleasePath = argv[++i]; + } else if (arg === "--assets-dir") { + args.assetsDir = argv[++i]; + } else if (arg === "--repository") { + args.repository = argv[++i]; + } else if (arg === "--api-base") { + args.apiBase = argv[++i]; + } else if (arg === "--replace-assets") { + args.skipExistingAssets = false; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!args.token) { + throw new Error("ATOMGIT_TOKEN is required."); + } + if (!args.githubReleasePath || !args.assetsDir) { + throw new Error("Usage: sync-atomgit-release.mjs --github-release --assets-dir "); + } + if (!args.repository.includes("/")) { + throw new Error(`Invalid AtomGit repository: ${args.repository}`); + } + if (!Number.isInteger(args.concurrency) || args.concurrency < 1) { + throw new Error("ATOMGIT_UPLOAD_CONCURRENCY must be a positive integer."); + } + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const [owner, repo] = args.repository.split("/", 2); + const release = JSON.parse(readFileSync(args.githubReleasePath, "utf8")); + const tag = release.tagName || release.tag_name; + if (!tag) { + throw new Error("GitHub release JSON is missing tagName."); + } + + const client = new AtomGitClient({ apiBase: args.apiBase, token: args.token, owner, repo }); + const atomRelease = await ensureRelease(client, release); + const existingAssets = atomGitAssetNames(atomRelease); + const assets = localAssets(args.assetsDir); + if (assets.length === 0) { + console.log("No release assets found to sync."); + return; + } + + const pendingAssets = assets.filter((assetPath) => { + const name = basename(assetPath); + if (args.skipExistingAssets && existingAssets.has(name)) { + console.log(`Skipping existing AtomGit asset: ${name}`); + return false; + } + return true; + }); + + console.log(`Uploading ${pendingAssets.length} asset(s) with concurrency ${args.concurrency}.`); + await mapWithConcurrency(pendingAssets, args.concurrency, (assetPath) => uploadAsset(client, tag, assetPath)); +} + +async function ensureRelease(client, githubRelease) { + const tag = githubRelease.tagName || githubRelease.tag_name; + const payload = { + tag_name: tag, + name: githubRelease.name || tag, + body: githubRelease.body || "", + target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "", + prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease), + }; + + const existing = await client.getRelease(tag); + if (existing) { + console.log(`Updating existing AtomGit release: ${tag}`); + return client.updateRelease(tag, payload); + } + + console.log(`Creating AtomGit release: ${tag}`); + return client.createRelease(payload); +} + +async function uploadAsset(client, tag, filePath) { + const name = basename(filePath); + const size = statSync(filePath).size; + console.log(`Uploading ${name} (${size} bytes)`); + const uploadTarget = await client.getUploadTarget(tag, name); + const contentType = contentTypeFor(name); + const uploadHeaders = uploadTarget.headers.length > 0 + ? uploadTarget.headers + : [ + ["Authorization", `Bearer ${client.token}`], + ["X-Api-Version", "2023-02-21"], + ]; + const contentTypeHeader = headerValue(uploadHeaders, "Content-Type") || contentType; + const curlHeaders = uploadHeaders + .filter(([key]) => key.toLowerCase() !== "content-type") + .flatMap(([key, value]) => ["--header", `${key}: ${value}`]); + + // AtomGit returns a per-file upload URL plus required upload headers. + // Try raw PUT first and fall back to multipart POST for API-compatible deployments. + const putStatus = await runCurl([ + "--fail-with-body", + "--location", + "--show-error", + "--retry", + "3", + "--retry-delay", + "5", + "--request", + "PUT", + ...curlHeaders, + "--header", + `Content-Type: ${contentTypeHeader}`, + "--upload-file", + filePath, + uploadTarget.url, + ]); + if (putStatus === 0) { + return; + } + + console.warn(`PUT upload failed for ${name}; retrying as multipart POST.`); + const postStatus = await runCurl([ + "--fail-with-body", + "--location", + "--show-error", + "--retry", + "3", + "--retry-delay", + "5", + "--request", + "POST", + ...curlHeaders, + "--form", + `file=@${filePath};filename=${name};type=${contentTypeHeader}`, + uploadTarget.url, + ]); + if (postStatus !== 0) { + throw new Error(`Failed to upload ${name} to AtomGit.`); + } +} + +function runCurl(args) { + return new Promise((resolve, reject) => { + const child = spawn("curl", args, { stdio: "inherit" }); + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }); +} + +async function mapWithConcurrency(items, concurrency, worker) { + let nextIndex = 0; + async function runWorker() { + while (nextIndex < items.length) { + const index = nextIndex++; + await worker(items[index]); + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker)); +} + +function localAssets(dir) { + return readdirSync(dir) + .map((name) => join(dir, name)) + .filter((path) => statSync(path).isFile()) + .sort((a, b) => statSync(a).size - statSync(b).size || basename(a).localeCompare(basename(b))); +} + +function atomGitAssetNames(release) { + const names = new Set(); + const groups = [ + release?.assets, + release?.attach_files, + release?.attachFiles, + release?.files, + release?.attachments, + ]; + for (const group of groups) { + if (!Array.isArray(group)) { + continue; + } + for (const item of group) { + const name = item?.name || item?.file_name || item?.filename || item?.title; + if (name) { + names.add(name); + } + } + } + return names; +} + +function contentTypeFor(name) { + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".zip")) return "application/zip"; + if (name.endsWith(".gz") || name.endsWith(".tgz")) return "application/gzip"; + if (name.endsWith(".dmg")) return "application/x-apple-diskimage"; + if (name.endsWith(".exe") || name.endsWith(".msi")) return "application/octet-stream"; + if (name.endsWith(".deb")) return "application/vnd.debian.binary-package"; + if (name.endsWith(".rpm")) return "application/x-rpm"; + if (name.endsWith(".AppImage")) return "application/octet-stream"; + return "application/octet-stream"; +} + +function headerValue(headers, name) { + const normalizedName = name.toLowerCase(); + const entry = headers.find(([key]) => key.toLowerCase() === normalizedName); + return entry ? entry[1] : ""; +} + +class AtomGitClient { + constructor({ apiBase, token, owner, repo }) { + this.apiBase = apiBase.replace(/\/+$/, ""); + this.token = token; + this.owner = owner; + this.repo = repo; + } + + async getRelease(tag) { + const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, null, { + allow404: true, + }); + if (res.status === 404) { + return null; + } + return parseJsonResponse(res); + } + + async createRelease(payload) { + const res = await this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, payload, { + acceptStatuses: [409, 422], + }); + if (res.status === 409 || res.status === 422) { + return this.updateRelease(payload.tag_name, payload); + } + return parseJsonResponse(res); + } + + async updateRelease(tag, payload) { + const res = await this.request("PATCH", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, payload); + return parseJsonResponse(res); + } + + async getUploadTarget(tag, fileName) { + const params = new URLSearchParams({ file_name: fileName }); + const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}/upload_url?${params}`); + const body = await parseJsonResponse(res); + if (body && typeof body === "object" && !Array.isArray(body)) { + console.log(`AtomGit upload_url response keys: ${Object.keys(body).sort().join(", ")}`); + if (body.headers && typeof body.headers === "object") { + console.log(`AtomGit upload header keys: ${Object.keys(body.headers).sort().join(", ")}`); + } + } + const uploadUrl = typeof body === "string" ? body : body.url || body.upload_url || body.uploadUrl; + if (!uploadUrl) { + throw new Error(`AtomGit upload_url response did not include an upload URL: ${JSON.stringify(body)}`); + } + console.log(`AtomGit upload target: ${sanitizeUrlForLog(uploadUrl)}`); + return { + url: uploadUrl, + headers: normalizeUploadHeaders(typeof body === "string" ? null : body.headers), + }; + } + + async request(method, path, body = null, requestOptions = {}) { + const headers = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + "X-Api-Version": "2023-02-21", + }; + const fetchOptions = { method, headers }; + if (body) { + const cleanBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== "")); + headers["Content-Type"] = "application/json"; + fetchOptions.body = JSON.stringify(cleanBody); + } + + const res = await fetch(`${this.apiBase}${path}`, fetchOptions); + const acceptedStatus = requestOptions.acceptStatuses?.includes(res.status); + if (!res.ok && !(requestOptions.allow404 && res.status === 404) && !acceptedStatus) { + const text = await res.text(); + throw new Error(`AtomGit API ${method} ${path} failed with ${res.status}: ${text}`); + } + return res; + } +} + +async function parseJsonResponse(res) { + const text = await res.text(); + if (!text.trim()) { + return {}; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function normalizeUploadHeaders(headers) { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return []; + } + return Object.entries(headers) + .filter(([key, value]) => key && value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, String(value)]); +} + +function sanitizeUrlForLog(value) { + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`; + } catch { + return ""; + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/.github/scripts/sync-changelog.mjs b/.github/scripts/sync-changelog.mjs new file mode 100644 index 0000000..0676312 --- /dev/null +++ b/.github/scripts/sync-changelog.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = "t8y2/dbx"; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ""; +const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || ""; +const OUT_CN = "releases-cn.json"; +const OUT_EN = "releases-en.json"; +const LATEST_EN_OUT = "latest-en.json"; +const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json"; + +const SECTION_MAP = { + 新功能: "added", + Added: "added", + 改进: "improved", + Improved: "improved", + 修复: "fixed", + Fixed: "fixed", + 变更: "changed", + Changed: "changed", + 移除: "removed", + Removed: "removed", +}; + +export async function fetchAllReleases() { + const releases = []; + let page = 1; + while (true) { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: "application/vnd.github+json" } }); + if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`); + const data = await res.json(); + if (data.length === 0) break; + releases.push(...data); + page++; + } + return releases; +} + +export function stripDownloadSection(body) { + const markers = ["### 下载安装", "### Download", "### 系统要求", "### System Requirements"]; + let idx = body.length; + for (const m of markers) { + const i = body.indexOf(m); + if (i !== -1 && i < idx) idx = i; + } + return body.slice(0, idx).trim(); +} + +// 剥离 release notes 里的 issue 引用 (closes #xxx),保留贡献者署名 (contributed by @xxx)。 +// 同时处理纯 closes 括号和混在 contributed by 括号里的 closes 片段。 +function stripIssueRefs(text) { + return text + // 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456) + .replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "") + // closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx) + .replace(/\(closes #\d+,\s*/g, "(") + // contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx) + .replace(/,\s*closes #\d+/g, ""); +} + +// 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2, +// 可能是旧版脚本生成的(desc 仍含 closes #),在写文件前统一清理一次。 +function stripIssueRefsInReleases(json) { + for (const release of json.releases || []) { + for (const section of release.sections || []) { + for (const item of section.items || []) { + if (item.desc) item.desc = stripIssueRefs(item.desc); + } + } + } + return json; +} + +export function parseBody(body) { + const cleaned = stripDownloadSection(body); + const sections = []; + let current = null; + + for (const line of cleaned.split("\n")) { + const headerMatch = line.match(/^###\s+(.+)/); + if (headerMatch) { + const title = headerMatch[1].trim(); + const type = SECTION_MAP[title] || "other"; + current = { type, title, items: [] }; + sections.push(current); + continue; + } + + if (!current) continue; + + const itemMatch = line.match(/^-\s+\*\*(.+?)\*\*\s*[—–-]\s*(.+)/); + if (itemMatch) { + current.items.push({ title: itemMatch[1].trim(), desc: stripIssueRefs(itemMatch[2].trim()) }); + continue; + } + + const plainMatch = line.match(/^-\s+(.+)/); + if (plainMatch) { + current.items.push({ title: plainMatch[1].trim(), desc: "" }); + } + } + + return sections.filter((s) => s.items.length > 0); +} + +export function buildReleaseSourceHash(release) { + return createHash("sha256") + .update( + JSON.stringify({ + tag: release.tag_name, + name: release.name || release.tag_name, + publishedAt: release.published_at || "", + body: release.body || "", + }), + ) + .digest("hex"); +} + +export function buildReleasesJson(releases, now = new Date()) { + return { + updatedAt: now.toISOString(), + releases: releases + .filter((r) => !r.draft && !r.prerelease && !r.tag_name.startsWith("agents-")) + .sort((a, b) => new Date(b.published_at) - new Date(a.published_at)) + .map((r) => ({ + tag: r.tag_name, + name: r.name || r.tag_name, + date: r.published_at.slice(0, 10), + _sourceHash: buildReleaseSourceHash(r), + sections: parseBody(r.body || ""), + })), + }; +} + +function releaseToMarkdown(release) { + return release.sections + .map((s) => { + const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join("\n"); + return `### ${s.title}\n${items}`; + }) + .join("\n\n"); +} + +// 给应用内更新提示用的英文 notes:只取最新一条版本(releases 已按 published_at 降序), +// 转成 md。version 用 tag(如 v0.5.47),应用端 normalize_version 后与 latest.json 的 version 校验。 +function buildLatestEnNotes(enReleasesJson) { + const latest = enReleasesJson.releases?.[0]; + if (!latest) return null; + return { + version: latest.tag, + notes: releaseToMarkdown(latest), + }; +} + +export async function fetchCachedEnglish({ cacheUrl = EN_CACHE_URL, fetchImpl = fetch } = {}) { + try { + const res = await fetchImpl(cacheUrl, { headers: { Accept: "application/json" } }); + if (!res.ok) { + console.warn(`English changelog cache unavailable: ${res.status}`); + return null; + } + return await res.json(); + } catch (err) { + console.warn(`English changelog cache unavailable: ${err.message}`); + return null; + } +} + +export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseekApiKey = DEEPSEEK_API_KEY, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) { + const cachedByTag = new Map((cachedEnJson?.releases || []).map((release) => [release.tag, release])); + const enReleases = []; + let reusedCount = 0; + let translatedCount = 0; + let skippedCount = 0; + + // 无 API key:仅复用 R2 上的英文缓存,未命中的条目回退中文以保证文件完整。 + // 这样本地(无 key)只要 R2 缓存新鲜也能产出英文 CHANGELOG;CI 有 key 时走正常翻译流程。 + if (!deepseekApiKey) { + console.warn("DEEPSEEK_API_KEY not set, falling back to cached English translations only"); + for (const release of cnJson.releases) { + const cachedRelease = cachedByTag.get(release.tag); + if (cachedRelease?._sourceHash === release._sourceHash) { + enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash }); + reusedCount++; + } else { + enReleases.push(release); + skippedCount++; + } + } + console.log(`English changelog cache reused ${reusedCount}, skipped ${skippedCount} (no API key)`); + return { updatedAt: cnJson.updatedAt, releases: enReleases }; + } + + for (const release of cnJson.releases) { + const cachedRelease = cachedByTag.get(release.tag); + if (cachedRelease?._sourceHash === release._sourceHash) { + enReleases.push({ + ...cachedRelease, + name: release.name, + date: release.date, + _sourceHash: release._sourceHash, + }); + reusedCount++; + continue; + } + + const sectionsText = releaseToMarkdown(release); + + if (!sectionsText.trim()) { + enReleases.push({ ...release, sections: [] }); + continue; + } + + const res = await fetchImpl("https://api.deepseek.com/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekApiKey}` }, + body: JSON.stringify({ + model: "deepseek-chat", + messages: [ + { + role: "system", + content: + "You are a technical translator. Translate the following Chinese software changelog to English. Keep the exact markdown format (### headers, - bullet points, **bold** titles, — dashes). Only translate, do not add or remove content. Keep technical terms, product names, and contributor names unchanged.", + }, + { role: "user", content: sectionsText }, + ], + temperature: 0.1, + }), + }); + + if (!res.ok) { + console.error(`DeepSeek API error for ${release.tag}: ${res.status}`); + enReleases.push(release); + continue; + } + + const data = await res.json(); + const translated = data.choices?.[0]?.message?.content || ""; + const enSections = parseBody(translated); + enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections }); + translatedCount++; + + await sleep(200); + } + + console.log(`English changelog cache reused ${reusedCount} release(s), translated ${translatedCount} release(s)`); + return { updatedAt: cnJson.updatedAt, releases: enReleases }; +} + +async function main() { + console.log("Fetching releases from GitHub..."); + const releases = await fetchAllReleases(); + console.log(`Found ${releases.length} releases`); + + const cnJson = buildReleasesJson(releases); + console.log(`Processed ${cnJson.releases.length} non-draft releases`); + + writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2)); + console.log(`Wrote ${OUT_CN}`); + + console.log("Fetching cached English changelog..."); + const cachedEnJson = await fetchCachedEnglish(); + + console.log("Translating to English..."); + const enJson = await translateToEnglish(cnJson, { cachedEnJson }); + if (enJson) { + // cache 复用的英文翻译可能来自旧版 R2 数据(desc 含 closes # 引用),统一剥离一次 + stripIssueRefsInReleases(enJson); + writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2)); + console.log(`Wrote ${OUT_EN}`); + + // 应用内更新提示用的英文 notes(单条最新版本) + const latestEn = buildLatestEnNotes(enJson); + if (latestEn) { + writeFileSync(LATEST_EN_OUT, JSON.stringify(latestEn, null, 2)); + console.log(`Wrote ${LATEST_EN_OUT}`); + } + } + + console.log("Done!"); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/.github/scripts/sync-cnb-release.mjs b/.github/scripts/sync-cnb-release.mjs new file mode 100644 index 0000000..52c355d --- /dev/null +++ b/.github/scripts/sync-cnb-release.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +import { createReadStream, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +const DEFAULT_API_BASE = "https://api.cnb.cool"; +const DEFAULT_REPOSITORY = "dbxio.com/dbx"; +const DEFAULT_CONCURRENCY = 3; +const MAX_ATTEMPTS = 4; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const githubRelease = JSON.parse(readFileSync(args.githubReleasePath, "utf8")); + const tag = githubRelease.tagName || githubRelease.tag_name; + if (!tag) throw new Error("GitHub release JSON is missing tagName."); + + const client = new CnbClient(args); + const release = await client.ensureRelease(tag, githubRelease); + const existingAssets = new Set((release.assets || []).map((asset) => asset.name)); + const assets = localAssets(args.assetsDir).filter((assetPath) => { + const name = basename(assetPath); + if (existingAssets.has(name)) { + console.log(`Skipping existing CNB asset: ${name}`); + return false; + } + return true; + }); + + console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`); + await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath)); +} + +function parseArgs(argv) { + const args = { + apiBase: process.env.CNB_API_BASE || DEFAULT_API_BASE, + repository: process.env.CNB_REPOSITORY || DEFAULT_REPOSITORY, + token: process.env.CNB_TOKEN || "", + concurrency: Number.parseInt(process.env.CNB_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10), + githubReleasePath: "", + assetsDir: "", + }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--github-release") args.githubReleasePath = argv[++index]; + else if (arg === "--assets-dir") args.assetsDir = argv[++index]; + else throw new Error(`Unknown argument: ${arg}`); + } + if (!args.token) throw new Error("CNB_TOKEN is required."); + if (!args.githubReleasePath || !args.assetsDir) { + throw new Error("Usage: sync-cnb-release.mjs --github-release --assets-dir "); + } + if (!Number.isInteger(args.concurrency) || args.concurrency < 1) { + throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer."); + } + return args; +} + +async function uploadWithRetry(client, releaseId, filePath) { + const name = basename(filePath); + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + console.log(`Uploading ${name}, attempt ${attempt}/${MAX_ATTEMPTS}.`); + await client.uploadAsset(releaseId, filePath); + console.log(`Uploaded ${name}.`); + return; + } catch (error) { + if (attempt === MAX_ATTEMPTS) throw error; + const delayMs = 2 ** (attempt - 1) * 5000; + console.warn(`Upload failed for ${name}: ${error.message}; retrying in ${delayMs / 1000}s.`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} + +class CnbClient { + constructor({ apiBase, repository, token }) { + this.apiBase = apiBase.replace(/\/+$/, ""); + this.repository = repository; + this.token = token; + } + + async ensureRelease(tag, githubRelease) { + const payload = { + tag_name: tag, + name: githubRelease.name || tag, + body: githubRelease.body || "", + prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease), + target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "", + }; + const existing = await this.request("GET", `/${this.repository}/-/releases/tags/${encodeURIComponent(tag)}`, null, true); + if (!existing) return this.request("POST", `/${this.repository}/-/releases`, payload); + + // Keep release metadata aligned while preserving the existing asset list used for resumable uploads. + await this.request("PATCH", `/${this.repository}/-/releases/${existing.id}`, { + name: payload.name, + body: payload.body, + }); + return existing; + } + + async uploadAsset(releaseId, filePath) { + const size = statSync(filePath).size; + const target = await this.request("POST", `/${this.repository}/-/releases/${releaseId}/asset-upload-url`, { + asset_name: basename(filePath), + size, + overwrite: false, + }); + const uploadResponse = await fetch(target.upload_url, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream", "Content-Length": `${size}` }, + body: createReadStream(filePath), + duplex: "half", + }); + if (!uploadResponse.ok) { + throw new Error(`CNB object upload failed with ${uploadResponse.status}: ${await uploadResponse.text()}`); + } + const verifyResponse = await fetch(decodeURIComponent(target.verify_url), { + method: "POST", + headers: this.headers(), + }); + if (!verifyResponse.ok) { + throw new Error(`CNB upload confirmation failed with ${verifyResponse.status}: ${await verifyResponse.text()}`); + } + } + + async request(method, path, body = null, allow404 = false) { + const response = await fetch(`${this.apiBase}${path}`, { + method, + headers: this.headers(body !== null), + body: body === null ? undefined : JSON.stringify(body), + }); + if (allow404 && response.status === 404) return null; + if (!response.ok) throw new Error(`CNB API ${method} ${path} failed with ${response.status}: ${await response.text()}`); + return response.status === 204 ? null : response.json(); + } + + headers(json = false) { + return { + Accept: "application/vnd.cnb.api+json", + Authorization: `Bearer ${this.token}`, + ...(json ? { "Content-Type": "application/json" } : {}), + }; + } +} + +function localAssets(dir) { + return readdirSync(dir) + .map((name) => join(dir, name)) + .filter((path) => statSync(path).isFile()) + .sort((left, right) => statSync(right).size - statSync(left).size || basename(left).localeCompare(basename(right))); +} + +async function mapWithConcurrency(items, concurrency, worker) { + let nextIndex = 0; + async function runWorker() { + while (nextIndex < items.length) { + const index = nextIndex++; + await worker(items[index]); + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/.github/scripts/sync-project-triage.mjs b/.github/scripts/sync-project-triage.mjs new file mode 100644 index 0000000..29ece77 --- /dev/null +++ b/.github/scripts/sync-project-triage.mjs @@ -0,0 +1,389 @@ +#!/usr/bin/env node + +const DEFAULT_PROJECT_OWNER = "t8y2"; +const DEFAULT_PROJECT_NUMBER = 1; +const DEFAULT_REPO = "t8y2/dbx"; + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = "true"; + continue; + } + args[key] = next; + i++; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); +// PROJECT_TOKEN is a PAT with project scope; GH_TOKEN / GITHUB_TOKEN (auto-generated) handles repo access +const projectToken = process.env.PROJECT_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || ""; +const repoTokens = [process.env.GH_TOKEN, process.env.GITHUB_TOKEN, process.env.PROJECT_TOKEN].filter(Boolean); +const repoToken = repoTokens[0] || ""; +const projectOwner = args["project-owner"] || process.env.PROJECT_OWNER || DEFAULT_PROJECT_OWNER; +const projectNumber = Number(args["project-number"] || process.env.PROJECT_NUMBER || DEFAULT_PROJECT_NUMBER); +const repo = args.repo || process.env.GITHUB_REPOSITORY || DEFAULT_REPO; +const [repoOwner, repoName] = repo.split("/"); +const issueNumber = args["issue-number"] ? Number(args["issue-number"]) : null; +const mode = args.backfill === "true" ? "backfill" : "issue"; +const eventAction = args["event-action"] || process.env.ISSUE_EVENT_ACTION || ""; + +if (!projectToken) { + throw new Error("PROJECT_TOKEN, GH_TOKEN, or GITHUB_TOKEN is required"); +} +if (!repoToken) { + throw new Error("GH_TOKEN or GITHUB_TOKEN is required for repository access"); +} + +if (!repoOwner || !repoName) { + throw new Error(`Invalid repo: ${repo}`); +} + +if (mode === "issue" && !issueNumber) { + throw new Error("--issue-number is required unless --backfill true is set"); +} + +function gqlString(value) { + return JSON.stringify(value); +} + +function gqlNumber(value) { + return Number(value); +} + +function gqlNullableString(value) { + return value == null ? "null" : JSON.stringify(value); +} + +async function graphql(query, token) { + const t = token || repoTokens[0]; + const resp = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `bearer ${t}`, + "Content-Type": "application/json", + "User-Agent": "dbx-project-triage/1.0", + }, + body: JSON.stringify({ query }), + }); + + const payload = await resp.json(); + if (payload.errors) { + throw new Error(`GraphQL request failed: ${JSON.stringify(payload.errors)}`); + } + if (!resp.ok) { + throw new Error(`GraphQL request failed: HTTP ${resp.status} ${resp.statusText}`); + } + return payload.data; +} + +function triageName(issue) { + const labels = new Set(issue.labels.nodes.map((label) => label.name)); + if (labels.has("question")) return "Needs Info"; + if (issue.assignees.nodes.length > 0) return "Ready"; + return "Inbox"; +} + +function singleSelectOptionsByName(field) { + return Object.fromEntries((field?.options || []).map((option) => [option.name, option.id])); +} + +function singleSelectOptionId(options, name) { + const exact = options[name]; + if (exact) return exact; + const target = name.toLowerCase(); + const entry = Object.entries(options).find(([optionName]) => optionName.toLowerCase() === target); + return entry?.[1]; +} + +async function getProjectConfig() { + const query = ` + query { + user(login: ${gqlString(projectOwner)}) { + projectV2(number: ${gqlNumber(projectNumber)}) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `; + + const data = await graphql(query, projectToken); + const project = data.user?.projectV2; + if (!project) { + throw new Error(`Project ${projectOwner}#${projectNumber} not found`); + } + + const triageField = project.fields.nodes.find((field) => field.name === "Triage"); + if (!triageField) { + throw new Error(`Project ${project.title} is missing a Triage field`); + } + const statusField = project.fields.nodes.find((field) => field.name === "Status"); + + return { + id: project.id, + title: project.title, + triageFieldId: triageField.id, + triageOptions: singleSelectOptionsByName(triageField), + statusFieldId: statusField?.id || "", + statusOptions: singleSelectOptionsByName(statusField), + }; +} + +async function fetchIssue(number) { + const query = ` + query { + repository(owner: ${gqlString(repoOwner)}, name: ${gqlString(repoName)}) { + issue(number: ${gqlNumber(number)}) { + id + number + title + url + state + assignees(first: 20) { + nodes { + login + } + } + labels(first: 50) { + nodes { + name + } + } + projectItems(first: 50) { + nodes { + id + project { + id + number + title + owner { + __typename + ... on User { + login + } + ... on Organization { + login + } + } + } + fieldValueByName(name: "Triage") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + } + } + statusValue: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + } + } + } + } + } + } + } + `; + + const data = await graphql(query, repoToken); + const issue = data.repository?.issue; + if (!issue) { + throw new Error(`Issue #${number} not found in ${repo}`); + } + return issue; +} + +async function addItemToProject(projectId, contentId) { + const mutation = ` + mutation { + addProjectV2ItemById(input: { projectId: ${gqlString(projectId)}, contentId: ${gqlString(contentId)} }) { + item { + id + } + } + } + `; + + const data = await graphql(mutation, projectToken); + + return data.addProjectV2ItemById.item.id; +} + +async function updateTriage({ projectId, itemId, fieldId, optionId }) { + const mutation = ` + mutation { + updateProjectV2ItemFieldValue( + input: { + projectId: ${gqlString(projectId)} + itemId: ${gqlString(itemId)} + fieldId: ${gqlString(fieldId)} + value: { singleSelectOptionId: ${gqlString(optionId)} } + } + ) { + projectV2Item { + id + } + } + } + `; + + await graphql(mutation, projectToken); +} + +async function updateSingleSelectField({ projectId, itemId, fieldId, optionId }) { + const mutation = ` + mutation { + updateProjectV2ItemFieldValue( + input: { + projectId: ${gqlString(projectId)} + itemId: ${gqlString(itemId)} + fieldId: ${gqlString(fieldId)} + value: { singleSelectOptionId: ${gqlString(optionId)} } + } + ) { + projectV2Item { + id + } + } + } + `; + + await graphql(mutation, projectToken); +} + +async function syncIssue(projectConfig, number) { + const issue = await fetchIssue(number); + const targetTriage = triageName(issue); + const optionId = projectConfig.triageOptions[targetTriage]; + if (!optionId) { + throw new Error(`Missing Triage option: ${targetTriage}`); + } + + let projectItem = issue.projectItems.nodes.find( + (item) => + item.project.id === projectConfig.id || + (item.project.number === projectNumber && item.project.owner?.login === projectOwner), + ); + + if (!projectItem) { + // 只有被 assign 的 issue 才进入 project 看板;未分配的留在 issue 列表, + // 等 assigned 事件触发后再纳入。已在 project 里的 issue 仍随 label/assign 变化同步。 + if (issue.assignees.nodes.length === 0) { + console.log(`Issue #${issue.number} has no assignee, skip adding to project`); + return; + } + const itemId = await addItemToProject(projectConfig.id, issue.id); + projectItem = { + id: itemId, + fieldValueByName: null, + statusValue: null, + }; + console.log(`Added issue #${issue.number} to ${projectConfig.title}`); + } + + const currentOptionId = projectItem.fieldValueByName?.optionId || ""; + if (currentOptionId !== optionId) { + await updateTriage({ + projectId: projectConfig.id, + itemId: projectItem.id, + fieldId: projectConfig.triageFieldId, + optionId, + }); + console.log(`Set issue #${issue.number} triage to ${targetTriage}`); + } else { + console.log(`Issue #${issue.number} triage already ${targetTriage}`); + } + + if (eventAction === "assigned" && issue.state === "OPEN") { + const targetStatus = "In Progress"; + const statusOptionId = singleSelectOptionId(projectConfig.statusOptions, targetStatus); + if (!projectConfig.statusFieldId) { + throw new Error(`Project ${projectConfig.title} is missing a Status field`); + } + if (!statusOptionId) { + throw new Error(`Missing Status option: ${targetStatus}`); + } + + const currentStatusOptionId = projectItem.statusValue?.optionId || ""; + if (currentStatusOptionId !== statusOptionId) { + await updateSingleSelectField({ + projectId: projectConfig.id, + itemId: projectItem.id, + fieldId: projectConfig.statusFieldId, + optionId: statusOptionId, + }); + console.log(`Set issue #${issue.number} status to ${targetStatus}`); + } else { + console.log(`Issue #${issue.number} status already ${targetStatus}`); + } + } +} + +async function fetchOpenIssueNumbers() { + const numbers = []; + let cursor = null; + while (true) { + const query = ` + query { + repository(owner: ${gqlString(repoOwner)}, name: ${gqlString(repoName)}) { + issues(first: 100, after: ${gqlNullableString(cursor)}, states: OPEN, orderBy: { field: CREATED_AT, direction: ASC }) { + nodes { + number + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + `; + const data = await graphql(query, repoToken); + const issues = data.repository?.issues; + if (!issues) break; + numbers.push(...issues.nodes.map((issue) => issue.number)); + if (!issues.pageInfo.hasNextPage) break; + cursor = issues.pageInfo.endCursor; + } + return numbers; +} + +async function main() { + const projectConfig = await getProjectConfig(); + if (mode === "backfill") { + const numbers = await fetchOpenIssueNumbers(); + console.log(`Backfilling ${numbers.length} open issues into ${projectConfig.title}`); + for (const number of numbers) { + await syncIssue(projectConfig, number); + } + return; + } + + await syncIssue(projectConfig, issueNumber); +} + +await main(); diff --git a/.github/workflows/1panel-appstore.yml b/.github/workflows/1panel-appstore.yml new file mode 100644 index 0000000..213864e --- /dev/null +++ b/.github/workflows/1panel-appstore.yml @@ -0,0 +1,59 @@ +name: 1Panel App Store + +on: + workflow_dispatch: + inputs: + version: + description: "Version to submit (e.g. 0.5.3)" + required: true + +jobs: + submit: + runs-on: ubuntu-latest + env: + REPO_OWNER: t8y2 + steps: + - uses: actions/checkout@v5 + + - name: Submit to 1Panel App Store + env: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: | + VERSION="${{ github.event.inputs.version }}" + + git clone --depth 1 -b localApps \ + "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/appstore-1panel.git" \ + appstore + cd appstore + + APP_DIR="apps/dbx" + mkdir -p "${APP_DIR}/latest" + + cp "${{ github.workspace }}/deploy/1panel/data.yml" "${APP_DIR}/data.yml" + cp "${{ github.workspace }}/deploy/1panel/README.md" "${APP_DIR}/README.md" + if [ -f "${{ github.workspace }}/deploy/1panel/logo.png" ]; then + cp "${{ github.workspace }}/deploy/1panel/logo.png" "${APP_DIR}/logo.png" + fi + cp -r "${{ github.workspace }}/deploy/1panel/latest/." "${APP_DIR}/latest/" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + BRANCH="app/dbx-${VERSION}" + git checkout -b "${BRANCH}" + git add "${APP_DIR}" + + if git diff --cached --quiet; then + echo "1Panel app store is already up to date." + else + git commit -m "feat(dbx): release ${VERSION}" + git push origin "${BRANCH}" + gh pr create \ + --repo "okxlin/appstore" \ + --head "${REPO_OWNER}:${BRANCH}" \ + --title "feat(dbx): release ${VERSION}" \ + --body "Automated release of DBX v${VERSION}" \ + --base localApps + echo "::notice::1Panel App Store PR created for ${VERSION}" + fi diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml new file mode 100644 index 0000000..dfab86c --- /dev/null +++ b/.github/workflows/agents-release.yml @@ -0,0 +1,620 @@ +name: Agents Release + +on: + push: + tags: ["agents-v*"] + +permissions: + contents: write + +concurrency: + group: dbx-release-main + cancel-in-progress: false + +jobs: + bump-versions: + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.bump.outputs.versions }} + prev_versions: ${{ steps.bump.outputs.prev_versions }} + prev_tag: ${{ steps.bump.outputs.prev_tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect changes and bump versions + id: bump + shell: bash + run: | + LEGACY_REPO="https://github.com/t8y2/dbx-agents.git" + LEGACY_BASE_TAG="v0.2.33" + PREV_TAG=$(git tag --sort=-creatordate | grep '^agents-v' | sed -n '2p') + SKIP_BUMP=false + MIGRATED_FIRST_RELEASE=false + PREV_VERSIONS_FILE="" + + if [ -z "$PREV_TAG" ]; then + echo "No previous agents-v tag found; treating this as the first migrated agents release" + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + + TMP_LEGACY="$(mktemp -d)" + git clone --depth 1 --branch "$LEGACY_BASE_TAG" "$LEGACY_REPO" "$TMP_LEGACY" + PREV_VERSIONS_FILE="$(mktemp)" + cp "$TMP_LEGACY/versions.json" "$PREV_VERSIONS_FILE" + rm -rf "$TMP_LEGACY" + + SKIP_BUMP=true + MIGRATED_FIRST_RELEASE=true + fi + echo "Comparing $PREV_TAG..HEAD" + + ARGS=(--prev-tag "$PREV_TAG" --migrated-first-release "$MIGRATED_FIRST_RELEASE" --write) + if [ "$SKIP_BUMP" = "true" ]; then + ARGS+=(--skip-bump) + fi + if [ -n "$PREV_VERSIONS_FILE" ]; then + ARGS+=(--prev-versions-file "$PREV_VERSIONS_FILE") + fi + + node .github/scripts/bump-agent-versions.mjs "${ARGS[@]}" + + if ! git diff --quiet -- agents/versions.json; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add agents/versions.json + git commit -m "chore: bump module versions [skip ci]" + git fetch origin main --no-tags + git rebase origin/main + git push origin HEAD:main + fi + + build-agents: + needs: [bump-versions] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 21 + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew shadowJar --parallel + working-directory: agents + - run: python3 scripts/validate_agent_jars.py + working-directory: agents + - uses: actions/upload-artifact@v4 + with: + name: agent-jars + path: "agents/drivers/*/build/libs/dbx-agent-*.jar" + + build-oracle-native: + needs: [bump-versions] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + - name: Test Oracle native agent + working-directory: agents/drivers/oracle-go + run: go test ./... + - name: Cross-compile Oracle native agent + shell: bash + run: | + mkdir -p release-native + cd agents/drivers/oracle-go + declare -A TARGETS=( + ["macos-aarch64"]="darwin/arm64" + ["macos-x64"]="darwin/amd64" + ["linux-aarch64"]="linux/arm64" + ["linux-x64"]="linux/amd64" + ["windows-aarch64"]="windows/arm64" + ["windows-x64"]="windows/amd64" + ) + for platform in "${!TARGETS[@]}"; do + IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}" + output="../../../release-native/dbx-agent-oracle-${platform}" + if [[ "$goos" == "windows" ]]; then + output="${output}.exe" + fi + echo "Building $platform ($goos/$goarch)" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" . + done + ls -lh ../../../release-native + - uses: actions/upload-artifact@v4 + with: + name: oracle-native + path: "release-native/dbx-agent-oracle-*" + + build-xugu-native: + needs: [bump-versions] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + - name: Test Xugu native agent + working-directory: agents/drivers/xugu + run: GONOSUMDB=gitee.com/XuguDB/go-xugu-driver go test ./... + - name: Cross-compile Xugu native agent + shell: bash + run: | + mkdir -p release-native + cd agents/drivers/xugu + declare -A TARGETS=( + ["macos-aarch64"]="darwin/arm64" + ["macos-x64"]="darwin/amd64" + ["linux-aarch64"]="linux/arm64" + ["linux-x64"]="linux/amd64" + ["windows-aarch64"]="windows/arm64" + ["windows-x64"]="windows/amd64" + ) + for platform in "${!TARGETS[@]}"; do + IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}" + output="../../../release-native/dbx-agent-xugu-${platform}" + if [[ "$goos" == "windows" ]]; then + output="${output}.exe" + fi + echo "Building $platform ($goos/$goarch)" + GONOSUMDB=gitee.com/XuguDB/go-xugu-driver CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" . + done + ls -lh ../../../release-native + - uses: actions/upload-artifact@v4 + with: + name: xugu-native + path: "release-native/dbx-agent-xugu-*" + + build-jre: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - jre-key: "21" + java-version: "21" + modules: "java.base,java.sql,java.sql.rowset,java.naming,java.management,java.desktop,java.security.jgss,java.security.sasl,jdk.security.auth,jdk.security.jgss,jdk.charsets,jdk.unsupported,java.scripting,java.compiler" + steps: + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + - name: Build JRE for all platforms + shell: bash + run: | + MODULES="${{ matrix.modules }}" + JLINK_OPTS="--strip-debug --no-header-files --no-man-pages --compress=zip-6" + JRE_KEY="${{ matrix.jre-key }}" + + jlink --add-modules $MODULES $JLINK_OPTS --output dbx-jre + tar czf dbx-jre-${JRE_KEY}-linux-x64.tar.gz dbx-jre + rm -rf dbx-jre + + ADOPTIUM="https://api.adoptium.net/v3/binary/latest/${{ matrix.java-version }}/ga" + declare -A PLATFORMS=( + ["macos-aarch64"]="mac/aarch64" + ["macos-x64"]="mac/x64" + ["linux-aarch64"]="linux/aarch64" + ["windows-x64"]="windows/x64" + ) + for platform in "${!PLATFORMS[@]}"; do + os_arch="${PLATFORMS[$platform]}" + echo "=== Downloading JDK for $platform ($os_arch) ===" + if [[ "$platform" == windows-* ]]; then + curl -L -o jdk-$platform.zip "$ADOPTIUM/$os_arch/jdk/hotspot/normal/eclipse?project=jdk" + mkdir -p jdk-$platform + unzip -q jdk-$platform.zip -d jdk-$platform-tmp + mv jdk-$platform-tmp/*/* jdk-$platform/ || mv jdk-$platform-tmp/* jdk-$platform/ + rm -rf jdk-$platform-tmp jdk-$platform.zip + else + curl -L -o jdk-$platform.tar.gz "$ADOPTIUM/$os_arch/jdk/hotspot/normal/eclipse?project=jdk" + mkdir -p jdk-$platform + tar xzf jdk-$platform.tar.gz -C jdk-$platform --strip-components=1 + rm -f jdk-$platform.tar.gz + fi + JAVA_HOME_CROSS="jdk-$platform" + if [ -d "$JAVA_HOME_CROSS/Contents/Home/jmods" ]; then + JMODS="$JAVA_HOME_CROSS/Contents/Home/jmods" + else + JMODS="$JAVA_HOME_CROSS/jmods" + fi + LLVM_OBJCOPY=$(ls /usr/bin/llvm-objcopy-* 2>/dev/null | sort -V | tail -1) + if [ -z "$LLVM_OBJCOPY" ]; then LLVM_OBJCOPY=$(which llvm-objcopy 2>/dev/null || true); fi + CROSS_OPTS="" + if [ -n "$LLVM_OBJCOPY" ]; then + CROSS_OPTS="--strip-native-debug-symbols=objcopy=$LLVM_OBJCOPY" + else + CROSS_OPTS="--disable-plugin strip-native-debug-symbols" + fi + jlink --module-path "$JMODS" --add-modules $MODULES $JLINK_OPTS $CROSS_OPTS --output dbx-jre + tar czf dbx-jre-${JRE_KEY}-$platform.tar.gz dbx-jre + rm -rf dbx-jre jdk-$platform + done + + ls -lh dbx-jre-*.tar.gz + - uses: actions/upload-artifact@v4 + with: + name: jre-${{ matrix.jre-key }} + path: "dbx-jre-*.tar.gz" + + release: + needs: [bump-versions, build-agents, build-oracle-native, build-xugu-native, build-jre] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Flatten artifacts + run: | + mkdir -p release + find artifacts/agent-jars -name '*.jar' -exec cp {} release/ \; + find artifacts/oracle-native -type f -name 'dbx-agent-oracle-*' -exec cp {} release/ \; + find artifacts/xugu-native -type f -name 'dbx-agent-xugu-*' -exec cp {} release/ \; + find artifacts -name 'dbx-jre-*.tar.gz' -exec cp {} release/ \; + ls -lh release/ + + - name: Generate agent-registry.json + env: + MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }} + run: | + TAG="${GITHUB_REF_NAME}" + REPO="${GITHUB_REPOSITORY}" + RELEASE_VERSION="${TAG#agents-v}" + + get_module_version() { + local name="$1" + echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name','${RELEASE_VERSION}'))" + } + + generate_jar_entry() { + local name="$1" label="$2" file="$3" jre_key="$4" version="$5" external_driver="$6" native_json="$7" + local sha256=$(sha256sum "$file" | cut -d' ' -f1) + local size=$(stat -c%s "$file") + local url="https://github.com/${REPO}/releases/download/${TAG}/$(basename $file)" + local native_line="" + if [ -n "$native_json" ]; then + native_line=" \"native\": {${native_json} + }," + fi + cat < release/agent-registry.json < /dev/null + echo "=== agent-registry.json ===" + cat release/agent-registry.json + + - name: Build offline ZIP bundles + run: bash agents/scripts/build_offline_zip.sh release + + - name: Generate release notes + env: + MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }} + PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }} + PREV_TAG: ${{ needs.bump-versions.outputs.prev_tag }} + run: | + NOTES="" + DRIVER_NAMES=() + for f in release/dbx-agent-*.jar; do + DRIVER_NAMES+=("$(basename "$f" .jar | sed 's/dbx-agent-//')") + done + for name in oracle xugu; do + [ -f "release/dbx-agent-${name}.jar" ] && continue + compgen -G "release/dbx-agent-${name}-*" > /dev/null && DRIVER_NAMES+=("$name") + done + + native_only_label() { + local name="$1" + case "$name" in + oracle) echo "Oracle" ;; + xugu) echo "虚谷 XuguDB" ;; + *) echo "$name" ;; + esac + } + + for name in "${DRIVER_NAMES[@]}"; do + old_ver=$(echo "$PREV_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))") + new_ver=$(echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))") + [ "$old_ver" = "$new_ver" ] && continue + + jar_file="release/dbx-agent-${name}.jar" + if [ -f "$jar_file" ]; then + label=$(unzip -p "$jar_file" META-INF/MANIFEST.MF | awk -F': ' 'BEGIN{IGNORECASE=1} /^Agent-Label:/ {sub(/\r$/, "", $2); print $2; exit}' || echo "$name") + [ -z "$label" ] && label="$name" + else + label=$(native_only_label "$name") + fi + + NOTES="${NOTES}### ${label} (${old_ver} → ${new_ver})"$'\n' + if [ "$name" = "oracle" ]; then + LOG_PATH="agents/drivers/oracle-go/" + elif [ -d "agents/drivers/$name" ]; then + LOG_PATH="agents/drivers/$name/" + else + LOG_PATH="agents/$name/" + fi + LOG_PATHS=("$LOG_PATH") + while IFS= read -r line; do + [ -n "$line" ] && NOTES="${NOTES}- ${line}"$'\n' + done < <(git log --oneline "$PREV_TAG"..HEAD -- "${LOG_PATHS[@]}" | sed 's/^[0-9a-f]* //') + + # include common changes if any + while IFS= read -r line; do + [ -n "$line" ] && NOTES="${NOTES}- ${line}"$'\n' + done < <(git log --oneline "$PREV_TAG"..HEAD -- agents/common/ | sed 's/^[0-9a-f]* //') + NOTES="${NOTES}"$'\n' + done + + if [ -z "$NOTES" ]; then + NOTES="Routine rebuild, no agent-specific changes."$'\n' + fi + + printf "## 更新内容\n\n%s" "$NOTES" > RELEASE_NOTES.md + echo "=== Release Notes ===" + cat RELEASE_NOTES.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release/* + body_path: RELEASE_NOTES.md + make_latest: false + + - name: Update agents-latest GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git tag -f agents-latest "$GITHUB_SHA" + git push origin refs/tags/agents-latest --force + + if gh release view agents-latest --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload agents-latest release/* --repo "$GITHUB_REPOSITORY" --clobber + gh release edit agents-latest \ + --repo "$GITHUB_REPOSITORY" \ + --title "Agents latest" \ + --notes "Moving release for the latest DBX agent registry, driver artifacts, managed JRE archives, and offline bundles." \ + --latest=false + else + gh release create agents-latest release/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "Agents latest" \ + --notes "Moving release for the latest DBX agent registry, driver artifacts, managed JRE archives, and offline bundles." \ + --latest=false + fi + + - name: Upload to R2 + env: + MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }} + PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINT: "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" + R2_BUCKET: "s3://${{ secrets.R2_BUCKET_NAME }}" + run: | + # Always upload agent-registry.json + aws s3 cp release/agent-registry.json \ + "$R2_BUCKET/agents/agent-registry.json" \ + --endpoint-url "$R2_ENDPOINT" + echo "Uploaded agent-registry.json" + + # Upload only changed driver JARs + UPLOADED=0 + SKIPPED=0 + for f in release/dbx-agent-*.jar; do + [ ! -f "$f" ] && continue + name=$(basename "$f" .jar | sed 's/dbx-agent-//') + old_ver=$(echo "$PREV_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))" 2>/dev/null) + new_ver=$(echo "$MODULE_VERSIONS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$name',''))" 2>/dev/null) + if [ "$old_ver" = "$new_ver" ] && [ -n "$old_ver" ]; then + echo "Skip $(basename $f) (unchanged $old_ver)" + SKIPPED=$((SKIPPED + 1)) + continue + fi + aws s3 cp "$f" \ + "$R2_BUCKET/agents/drivers/$(basename $f)" \ + --endpoint-url "$R2_ENDPOINT" + echo "Uploaded $(basename $f) ($old_ver → $new_ver)" + UPLOADED=$((UPLOADED + 1)) + done + echo "Drivers: $UPLOADED uploaded, $SKIPPED skipped" + + for f in release/dbx-agent-*; do + [ -f "$f" ] || continue + [[ "$f" != *.jar ]] || continue + aws s3 cp "$f" \ + "$R2_BUCKET/agents/drivers/$(basename $f)" \ + --endpoint-url "$R2_ENDPOINT" + echo "Uploaded $(basename $f)" + done + + # Upload JRE — always overwrite to ensure latest modules + for f in release/dbx-jre-*.tar.gz; do + [ ! -f "$f" ] && continue + key="agents/jre/$(basename $f)" + aws s3 cp "$f" "$R2_BUCKET/$key" --endpoint-url "$R2_ENDPOINT" + echo "Uploaded $(basename $f)" + done + + sync-release-to-cnb: + name: Sync agents releases to CNB + needs: release + if: ${{ always() && needs.release.result == 'success' }} + runs-on: ubuntu-latest + continue-on-error: true + strategy: + fail-fast: false + matrix: + tag: ["${{ github.ref_name }}", "agents-latest"] + steps: + - name: Sync release assets to CNB + env: + TAG_NAME: ${{ matrix.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_USERNAME: ${{ github.repository_owner }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CNB_REPOSITORY: dbxio.com/dbx + CNB_USERNAME: cnb + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + run: | + set -euo pipefail + wget -q https://cnb.cool/znb/mpgrm/-/releases/download/v0.1.0/mpgrm_linux_amd64.tar.gz + tar -xf mpgrm_linux_amd64.tar.gz + chmod +x mpgrm + ./mpgrm releases sync \ + --repo "https://github.com/${GITHUB_REPOSITORY}.git" \ + --target-repo "https://cnb.cool/${CNB_REPOSITORY}.git" \ + --tags "${TAG_NAME}" + + sync-release-to-atomgit: + name: Sync agents releases to AtomGit + needs: release + if: ${{ always() && needs.release.result == 'success' }} + runs-on: ubuntu-latest + continue-on-error: true + strategy: + fail-fast: false + matrix: + tag: ["${{ github.ref_name }}", "agents-latest"] + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Sync release assets to AtomGit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ matrix.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + ATOMGIT_REPOSITORY: t8y2/dbx + run: | + set -euo pipefail + git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" --clobber + node .github/scripts/sync-atomgit-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets" \ + ${{ matrix.tag == 'agents-latest' && '--replace-assets' || '' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5fa41de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,248 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUSTFLAGS: -C debuginfo=line-tables-only + +jobs: + frontend: + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: pnpm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Frontend check + run: pnpm check + + - name: Node package tests + run: pnpm test:packages + + - name: Node package publish dry run + run: pnpm publish:dry-run + + rust-fmt-clippy: + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup || true + docker system prune -af || true + df -h + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./ -> target" + shared-key: ci-rust-fmt-clippy-x86_64-unknown-linux-gnu + + - name: Cargo fmt check + run: cargo fmt --check + + - name: Cargo clippy + run: cargo clippy --workspace --locked --all-targets + + rust-test: + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup || true + docker system prune -af || true + df -h + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./ -> target" + shared-key: ci-rust-test-x86_64-unknown-linux-gnu + + - name: Cargo test + run: cargo test --workspace --locked + + rust: + needs: [changes, rust-fmt-clippy, rust-test] + if: always() && needs.changes.outputs.rust == 'true' + runs-on: ubuntu-22.04 + steps: + - name: Check Rust jobs + run: | + if [ "${{ needs.rust-fmt-clippy.result }}" != "success" ]; then + echo "rust-fmt-clippy result: ${{ needs.rust-fmt-clippy.result }}" + exit 1 + fi + if [ "${{ needs.rust-test.result }}" != "success" ]; then + echo "rust-test result: ${{ needs.rust-test.result }}" + exit 1 + fi + + jdbc: + needs: changes + if: needs.changes.outputs.jdbc == 'true' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: maven + + - name: JDBC plugin version guard + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + if [ -z "$BASE_SHA" ] || echo "$BASE_SHA" | grep -Eq '^0+$'; then + BASE_SHA="HEAD~1" + fi + node .github/scripts/check-jdbc-plugin-version.mjs "$BASE_SHA" HEAD + + - name: JDBC plugin package check + run: ./plugins/jdbc/package.sh + + changes: + runs-on: ubuntu-22.04 + outputs: + frontend: ${{ steps.filter.outputs.frontend }} + rust: ${{ steps.filter.outputs.rust }} + jdbc: ${{ steps.filter.outputs.jdbc }} + agents: ${{ steps.filter.outputs.agents }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Detect changed areas + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + frontend: + - 'apps/desktop/**' + - 'packages/**' + - 'pnpm-lock.yaml' + - 'package.json' + - '.oxfmtrc.json' + - 'scripts/run-check.mjs' + - '.github/workflows/ci.yml' + rust: + - 'crates/**' + - 'src-tauri/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain*' + - '.github/workflows/ci.yml' + jdbc: + - 'plugins/jdbc/**' + agents: + - 'agents/**' + + agents: + needs: changes + if: needs.changes.outputs.agents == 'true' + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: agents + steps: + - uses: actions/checkout@v5 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: | + 8 + 21 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Agent script tests + run: python3 -m unittest discover -s scripts -p '*_test.py' + + - name: Agent validation + run: python3 scripts/validate_agents.py + + - name: Oracle native agent tests + run: go test ./... + working-directory: agents/drivers/oracle-go + + - name: Xugu native agent tests + run: GONOSUMDB=gitee.com/XuguDB/go-xugu-driver go test ./... + working-directory: agents/drivers/xugu + + - name: Oracle native agent build + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-oracle-linux-x64 . + working-directory: agents/drivers/oracle-go + + - name: Xugu native agent build + run: GONOSUMDB=gitee.com/XuguDB/go-xugu-driver CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-xugu-linux-x64 . + working-directory: agents/drivers/xugu + + - name: Java agent tests and packages + run: ./gradlew test shadowJar --continue + + - name: Agent jar validation + run: python3 scripts/validate_agent_jars.py diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 0000000..2aa9958 --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -0,0 +1,126 @@ +name: Docker Dev + +on: + workflow_dispatch: + +env: + CNB_REPOSITORY: "dbxio.com/dbx" + CNB_REGISTRY: "docker.cnb.cool" + +jobs: + docker: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: [linux/amd64, linux/arm64] + steps: + - uses: actions/checkout@v5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to CNB Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.CNB_REGISTRY }} + username: cnb + password: ${{ secrets.CNB_TOKEN }} + + - name: Build and push Docker Hub by digest + id: build-dockerhub + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/dbx,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }}-dev + cache-to: type=gha,scope=${{ matrix.platform }}-dev,mode=max + + - name: Build and push CNB by digest + id: build-cnb + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }}-dev + + - name: Export digests + run: | + mkdir -p /tmp/digests/dockerhub /tmp/digests/cnb + dockerhub_digest="${{ steps.build-dockerhub.outputs.digest }}" + cnb_digest="${{ steps.build-cnb.outputs.digest }}" + touch "/tmp/digests/dockerhub/${dockerhub_digest#sha256:}" + touch "/tmp/digests/cnb/${cnb_digest#sha256:}" + + - name: Upload Docker Hub digest + uses: actions/upload-artifact@v7 + with: + name: dockerhub-digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/dockerhub/* + if-no-files-found: error + retention-days: 1 + + - name: Upload CNB digest + uses: actions/upload-artifact@v7 + with: + name: cnb-digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/cnb/* + if-no-files-found: error + retention-days: 1 + + docker-manifest: + needs: docker + runs-on: ubuntu-latest + steps: + - name: Download Docker Hub digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests/dockerhub + pattern: dockerhub-digests-* + merge-multiple: true + + - name: Download CNB digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests/cnb + pattern: cnb-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to CNB Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.CNB_REGISTRY }} + username: cnb + password: ${{ secrets.CNB_TOKEN }} + + - name: Create manifest list and push + run: | + docker buildx imagetools create \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/dbx:dev \ + $(cd /tmp/digests/dockerhub && printf '${{ secrets.DOCKERHUB_USERNAME }}/dbx@sha256:%s ' *) + + docker buildx imagetools create \ + -t ${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:dev \ + $(cd /tmp/digests/cnb && printf '${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}@sha256:%s ' *) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..352bab3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +name: Deploy Docs + +on: + push: + branches: [main] + paths: ["docs/**"] + workflow_run: + workflows: ["Publish Packages"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - uses: pnpm/action-setup@v6 + - run: cd docs && pnpm install --frozen-lockfile --ignore-workspace && pnpm build + - name: Deploy to Cloudflare Workers + working-directory: docs + run: pnpm dlx wrangler@4 deploy + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/i18n-autofill.yml b/.github/workflows/i18n-autofill.yml new file mode 100644 index 0000000..198cbd4 --- /dev/null +++ b/.github/workflows/i18n-autofill.yml @@ -0,0 +1,154 @@ +name: I18n Autofill + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "apps/desktop/src/i18n/locales/**" + - ".github/scripts/i18n-autofill.mjs" + - ".github/workflows/i18n-autofill.yml" + +permissions: + contents: write + issues: write + pull-requests: read + +concurrency: + group: i18n-autofill-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + autofill: + if: github.event.pull_request.draft == false + runs-on: ubuntu-22.04 + steps: + - name: Checkout trusted base script + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Copy trusted script + run: cp .github/scripts/i18n-autofill.mjs "$RUNNER_TEMP/i18n-autofill.mjs" + + - name: Checkout pull request branch + uses: actions/checkout@v5 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + token: ${{ secrets.I18N_BOT_TOKEN || secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Fetch base branch + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + git remote add base "https://github.com/${{ github.repository }}.git" + git fetch --no-tags --depth=1 base "${BASE_REF}" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + + - name: Autofill missing i18n entries + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + DEEPSEEK_MODEL: ${{ vars.DEEPSEEK_MODEL || 'deepseek-v4-pro' }} + I18N_BASE_REF: base/${{ github.event.pull_request.base.ref }} + I18N_SUMMARY_FILE: ${{ runner.temp }}/i18n-summary.json + run: node "$RUNNER_TEMP/i18n-autofill.mjs" --write + + - name: Commit and push translation patch + id: patch + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + if git diff --quiet -- apps/desktop/src/i18n/locales; then + echo "No i18n autofill changes to commit" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git config user.name "dbx-i18n-bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apps/desktop/src/i18n/locales + git commit -m "chore(i18n): autofill new translations" + if git push origin "HEAD:${PR_HEAD_REF}"; then + echo "push_failed=false" >> "$GITHUB_OUTPUT" + else + echo "push_failed=true" >> "$GITHUB_OUTPUT" + git diff HEAD~1 HEAD -- apps/desktop/src/i18n/locales > "$RUNNER_TEMP/i18n-autofill.patch" + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Comment autofill result + if: steps.patch.outputs.changed == 'true' && steps.patch.outputs.push_failed != 'true' + env: + GH_TOKEN: ${{ secrets.I18N_BOT_TOKEN || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SUMMARY_FILE: ${{ runner.temp }}/i18n-summary.json + BODY_FILE: ${{ runner.temp }}/i18n-autofill-comment.md + run: | + node <<'NODE' + const fs = require("node:fs"); + + const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, "utf8")); + const keyCount = summary.newKeys.length; + const localeRows = summary.updatedLocales + .map(({ locale, count }) => `| \`${locale}\` | ${count} |`) + .join("\n"); + const body = [ + "### 🌐 I18n autofill completed", + "", + `✅ Added translations for **${keyCount}** new \`${summary.sourceLocale}\` i18n ${keyCount === 1 ? "key" : "keys"}.`, + "", + "| Locale | Added entries |", + "| --- | ---: |", + localeRows, + ].join("\n"); + + fs.writeFileSync(process.env.BODY_FILE, `${body}\n`); + NODE + + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$BODY_FILE" + + - name: Comment push failure + if: steps.patch.outputs.changed == 'true' && steps.patch.outputs.push_failed == 'true' + env: + GH_TOKEN: ${{ secrets.I18N_BOT_TOKEN || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SUMMARY_FILE: ${{ runner.temp }}/i18n-summary.json + PATCH_FILE: ${{ runner.temp }}/i18n-autofill.patch + BODY_FILE: ${{ runner.temp }}/i18n-autofill-push-failed.md + run: | + node <<'NODE' + const fs = require("node:fs"); + + const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, "utf8")); + const patch = fs.readFileSync(process.env.PATCH_FILE, "utf8"); + const keyCount = summary.newKeys.length; + const localeRows = summary.updatedLocales + .map(({ locale, count }) => `| \`${locale}\` | ${count} |`) + .join("\n"); + const patchPreview = patch.length > 60000 + ? `${patch.slice(0, 60000)}\n\n[Patch truncated in comment. Please check the workflow artifact/log for the full diff.]` + : patch; + const body = [ + "### ⚠️ I18n autofill needs manual apply", + "", + `I generated translations for **${keyCount}** new \`${summary.sourceLocale}\` i18n ${keyCount === 1 ? "key" : "keys"}, but could not push a patch to this PR branch.`, + "", + "| Locale | Missing entries |", + "| --- | ---: |", + localeRows, + "", + "#### Patch preview", + "```diff", + patchPreview, + "```", + ].join("\n"); + + fs.writeFileSync(process.env.BODY_FILE, `${body}\n`); + NODE + + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$BODY_FILE" diff --git a/.github/workflows/issue-claim.yml b/.github/workflows/issue-claim.yml new file mode 100644 index 0000000..6ba9c0e --- /dev/null +++ b/.github/workflows/issue-claim.yml @@ -0,0 +1,30 @@ +name: Issue Claim + +on: + issue_comment: + types: [created] + +concurrency: + group: issue-claim-${{ github.event.issue.number }} + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + claim: + runs-on: ubuntu-latest + if: ${{ !github.event.issue.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v5 + + - name: Handle /claim command + run: node .github/scripts/issue-claim.mjs + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_USER: ${{ github.event.comment.user.login }} + COMMENT_USER_TYPE: ${{ github.event.comment.user.type }} diff --git a/.github/workflows/issue-database-label.yml b/.github/workflows/issue-database-label.yml new file mode 100644 index 0000000..df17320 --- /dev/null +++ b/.github/workflows/issue-database-label.yml @@ -0,0 +1,30 @@ +name: Issue Triage Labels + +on: + issues: + types: [opened, edited, reopened] + +concurrency: + group: issue-database-label-${{ github.event.issue.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + +jobs: + label: + runs-on: ubuntu-latest + if: ${{ !github.event.issue.pull_request }} + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + + - name: Triage issue labels and title + run: node .github/scripts/label-issue-database.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml new file mode 100644 index 0000000..f758a6a --- /dev/null +++ b/.github/workflows/mcp-release.yml @@ -0,0 +1,342 @@ +name: Node Packages Release + +on: + workflow_dispatch: + inputs: + version: + description: "Package version to publish, for example 0.4.4" + required: true + +permissions: + contents: write + id-token: write + +concurrency: + group: dbx-release-main + cancel-in-progress: false + +jobs: + prepare: + name: Prepare package release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Check npm token + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "${NODE_AUTH_TOKEN}" ]; then + echo "::error::NPM_TOKEN secret is required to publish DBX Node packages." + exit 1 + fi + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set package versions + id: version + env: + VERSION: ${{ github.event.inputs.version }} + run: | + node <<'NODE' + const fs = require("fs"); + + const version = process.env.VERSION.trim(); + if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid semver version: ${version}`); + } + + const readJson = (path) => JSON.parse(fs.readFileSync(path, "utf8")); + const writeJson = (path, data) => fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`); + + for (const path of [ + "packages/node-core/package.json", + "packages/cli/package.json", + "packages/mcp-server/package.json", + ]) { + const pkg = readJson(path); + pkg.version = version; + writeJson(path, pkg); + } + + const serverPath = "packages/mcp-server/server.json"; + const server = readJson(serverPath); + server.version = version; + for (const packageInfo of server.packages ?? []) { + if (packageInfo.registryType === "npm" && packageInfo.identifier === "@dbx-app/mcp-server") { + packageInfo.version = version; + } + } + writeJson(serverPath, server); + + fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=packages-v${version}\n`); + NODE + + - name: Run package tests + run: pnpm test:packages + + - name: Build and pack packages + run: pnpm publish:dry-run + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit package release version + run: | + VERSION="${{ steps.version.outputs.version }}" + git add packages/node-core/package.json packages/cli/package.json packages/mcp-server/package.json packages/mcp-server/server.json + if git diff --cached --quiet; then + echo "Package versions already committed for ${VERSION}." + else + git commit -m "chore(packages): release ${VERSION} [skip node-packages-release]" + fi + + - name: Rebase package release commit and push + env: + RELEASE_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN }} + run: | + TAG="${{ steps.version.outputs.tag }}" + if [ -n "${RELEASE_TOKEN}" ]; then + git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + fi + git fetch origin main --no-tags + git rebase origin/main + REMOTE_TAG_SHA="$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}')" + HEAD_SHA="$(git rev-parse HEAD)" + if [ -n "${REMOTE_TAG_SHA}" ]; then + if [ "${REMOTE_TAG_SHA}" != "${HEAD_SHA}" ]; then + echo "::error::Remote tag ${TAG} already exists at ${REMOTE_TAG_SHA}, expected ${HEAD_SHA}." + exit 1 + fi + echo "Remote tag ${TAG} already points at ${HEAD_SHA}." + else + git tag -f "${TAG}" "${HEAD_SHA}" + fi + git push origin HEAD:main + if [ -z "${REMOTE_TAG_SHA}" ]; then + git push origin "refs/tags/${TAG}:refs/tags/${TAG}" + fi + + publish-node-core: + name: Publish node-core + runs-on: ubuntu-latest + needs: prepare + outputs: + published-or-existing: ${{ steps.publish.outputs.published_or_existing }} + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Publish Node core + id: publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + if npm view "@dbx-app/node-core@${VERSION}" version >/dev/null 2>&1; then + echo "@dbx-app/node-core@${VERSION} already exists on npm; skipping." + echo "published_or_existing=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + pnpm --filter @dbx-app/node-core publish --access public --provenance --no-git-checks + echo "published_or_existing=true" >> "$GITHUB_OUTPUT" + + publish-leaf-packages: + name: Publish ${{ matrix.package-name }} + runs-on: ubuntu-latest + needs: [prepare, publish-node-core] + strategy: + fail-fast: false + matrix: + include: + - package-name: "@dbx-app/cli" + filter: "@dbx-app/cli" + - package-name: "@dbx-app/mcp-server" + filter: "@dbx-app/mcp-server" + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build workspace dependencies + run: pnpm --filter @dbx-app/node-core build + + - name: Publish package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + PACKAGE_NAME: ${{ matrix.package-name }} + PACKAGE_FILTER: ${{ matrix.filter }} + run: | + if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then + echo "${PACKAGE_NAME}@${VERSION} already exists on npm; skipping." + exit 0 + fi + pnpm --filter "${PACKAGE_FILTER}" publish --access public --provenance --no-git-checks + + publish-homebrew-formula: + name: Publish Homebrew formula + runs-on: ubuntu-latest + needs: [prepare, publish-leaf-packages] + steps: + - name: Download CLI npm tarball and compute SHA256 + id: cli-hash + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + VERSION="${VERSION}" + NPM_TARBALL="cli-${VERSION}.tgz" + NPM_URL="https://registry.npmjs.org/@dbx-app/cli/-/${NPM_TARBALL}" + + # Poll npm until the package is available (CDN propagation delay) + for i in $(seq 1 12); do + if curl -fsSLI "${NPM_URL}" >/dev/null 2>&1; then + echo "Package found on attempt ${i}" + break + fi + echo "Waiting for npm CDN (attempt ${i}/12)..." + sleep 10 + done + + curl -fsSL -o "${NPM_TARBALL}" "${NPM_URL}" + CLI_SHA256=$(sha256sum "${NPM_TARBALL}" | cut -d ' ' -f 1) + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "sha256=${CLI_SHA256}" >> "$GITHUB_OUTPUT" + echo " cli version: ${VERSION}" + echo " sha256: ${CLI_SHA256}" + + - name: Push formula to homebrew-tap + env: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + CLI_VERSION: ${{ steps.cli-hash.outputs.version }} + CLI_SHA256: ${{ steps.cli-hash.outputs.sha256 }} + run: | + git clone --depth 1 \ + "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/t8y2/homebrew-tap.git" \ + homebrew-tap + cd homebrew-tap + mkdir -p Formula + + cat > Formula/dbx-cli.rb <<'RUBY_EOF' + class DbxCli < Formula + desc "Command-line interface for DBX database connections, schema, and safe queries" + homepage "https://github.com/t8y2/dbx" + url "https://registry.npmjs.org/@dbx-app/cli/-/cli-__CLI_VERSION__.tgz" + sha256 "__CLI_SHA256__" + license "Apache-2.0" + + depends_on "node" + + on_linux do + depends_on "pkgconf" => :build + depends_on "libsecret" + end + + def install + system "npm", "install", *std_npm_args + bin.install_symlink libexec.glob("bin/*") + + # Rebuild better-sqlite3 and keytar native bindings for the current platform. + # prebuild-install is blocked by the Homebrew sandbox during npm install, + # so we must rebuild them explicitly via node-gyp. + node_modules = libexec/"lib/node_modules/@dbx-app/cli/node_modules" + + cd node_modules/"better-sqlite3" do + system "npm", "run", "build-release" + end + + cd node_modules/"keytar" do + rm_r "prebuilds" if File.directory?("prebuilds") + system "npm", "run", "build" + end + end + + test do + assert_path_exists bin/"dbx" + system bin/"dbx", "doctor" + end + end + RUBY_EOF + + sed -i 's/^ //' Formula/dbx-cli.rb + sed -i "s/__CLI_VERSION__/${CLI_VERSION}/g" Formula/dbx-cli.rb + sed -i "s/__CLI_SHA256__/${CLI_SHA256}/g" Formula/dbx-cli.rb + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/dbx-cli.rb + + if git diff --cached --quiet; then + echo "CLI Homebrew formula is already up to date." + else + git commit -m "dbx-cli ${CLI_VERSION}" + git push + echo "::notice::CLI Homebrew formula updated to ${CLI_VERSION}" + fi diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml new file mode 100644 index 0000000..12649a2 --- /dev/null +++ b/.github/workflows/notify.yml @@ -0,0 +1,200 @@ +name: Notifications + +on: + # issues: + # types: [opened] + pull_request_target: + types: [opened, closed] + release: + types: [published, released] + +jobs: + notify: + runs-on: ubuntu-latest + if: ${{ github.event_name != 'release' || (!github.event.release.draft && !github.event.release.prerelease) }} + steps: + - name: Build message + id: msg + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + # ISSUE_TITLE: ${{ github.event.issue.title }} + # ISSUE_URL: ${{ github.event.issue.html_url }} + # ISSUE_USER: ${{ github.event.issue.user.login }} + # ISSUE_BODY: ${{ github.event.issue.body }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_USER: ${{ github.event.pull_request.user.login }} + PR_MERGED: ${{ github.event.pull_request.merged }} + PR_MERGED_BY: ${{ github.event.pull_request.merged_by.login }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_URL: ${{ github.event.release.html_url }} + RELEASE_BODY: ${{ github.event.release.body }} + run: | + python3 - <<'PYEOF' + import os + + event = os.environ["EVENT_NAME"] + action = os.environ.get("EVENT_ACTION", "") + msg = None + + # if event == "issues": + # title = os.environ["ISSUE_TITLE"] + # url = os.environ["ISSUE_URL"] + # user = os.environ["ISSUE_USER"] + # msg = "\n".join([ + # "📋 新 Issue", + # f"标题: {title}", + # f"提交者: {user}", + # f"链接: {url}", + # ]) + # + # elif event == "release": + if event == "release": + tag = os.environ["RELEASE_TAG"] + name = os.environ["RELEASE_NAME"] + url = os.environ["RELEASE_URL"] + body = os.environ.get("RELEASE_BODY", "")[:800] + + import re + body = re.sub(r'#{1,6}\s*', '', body) + body = re.sub(r'\*\*(.+?)\*\*', r'\1', body) + body = re.sub(r'\*(.+?)\*', r'\1', body) + body = re.sub(r'`(.+?)`', r'\1', body) + body = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', body) + body = re.sub(r'\n{3,}', '\n\n', body) + body = body.strip() + + msg = "\n".join([ + f"🚀 新版本发布 {tag}", + "", + body, + "", + f"下载: {url}", + ]) + + elif event in ("pull_request", "pull_request_target"): + title = os.environ["PR_TITLE"] + url = os.environ["PR_URL"] + if action == "opened": + user = os.environ["PR_USER"] + msg = "\n".join([ + "🔀 新 Pull Request", + f"标题: {title}", + f"提交者: {user}", + f"链接: {url}", + ]) + elif action == "closed" and os.environ.get("PR_MERGED") == "true": + user = os.environ.get("PR_MERGED_BY", "unknown") + msg = "\n".join([ + "✅ PR 已合并", + f"标题: {title}", + f"合并者: {user}", + f"链接: {url}", + ]) + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + if msg: + f.write(f"skip=false\n") + delim = "EOF_MSG" + f.write(f"msg<<{delim}\n{msg}\n{delim}\n") + else: + f.write(f"skip=true\n") + PYEOF + + # - name: AI Analysis for Issue + # id: ai + # if: ${{ github.event_name == 'issues' && steps.msg.outputs.skip != 'true' }} + # env: + # ISSUE_TITLE: ${{ github.event.issue.title }} + # ISSUE_BODY: ${{ github.event.issue.body }} + # ISSUE_USER: ${{ github.event.issue.user.login }} + # DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + # run: | + # python3 - <<'PYEOF' + # import os, json, urllib.request + # + # title = os.environ["ISSUE_TITLE"] + # body = (os.environ.get("ISSUE_BODY") or "(无描述)")[:800] + # user = os.environ.get("ISSUE_USER", "unknown") + # api_key = os.environ.get("DEEPSEEK_API_KEY", "") + # + # if not api_key: + # with open(os.environ["GITHUB_OUTPUT"], "a") as f: + # f.write("analysis=\n") + # exit(0) + # + # prompt = f"""分析这个 GitHub Issue,用中文回复。格式要求(严格遵守,不要多余内容): + # + # 总结: [一句话概括核心问题] + # 优先级: [P0紧急/P1重要/P2普通/P3低优] + # 合理性: [有效问题/重复报告/信息不足/不是bug] + # 建议: [一句话给出处理建议或关联方向] + # + # --- + # 标题: {title} + # 提交者: {user} + # 内容: {body}""" + # + # req = urllib.request.Request( + # "https://api.deepseek.com/v1/chat/completions", + # data=json.dumps({ + # "model": "deepseek-v4-pro", + # "max_tokens": 300, + # "messages": [{"role": "user", "content": prompt}], + # }).encode(), + # headers={ + # "Authorization": f"Bearer {api_key}", + # "Content-Type": "application/json", + # }, + # ) + # + # try: + # resp = urllib.request.urlopen(req, timeout=20) + # data = json.loads(resp.read()) + # text = data["choices"][0]["message"]["content"] + # except Exception as e: + # text = f"(AI 分析暂时不可用: {e})" + # + # with open(os.environ["GITHUB_OUTPUT"], "a") as f: + # delim = "EOF_ANALYSIS" + # f.write(f"analysis<<{delim}\n{text}\n{delim}\n") + # PYEOF + + - name: Send to Feishu + if: ${{ steps.msg.outputs.skip != 'true' && github.event_name != 'release' }} + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} + MSG_CONTENT: ${{ steps.msg.outputs.msg }} + run: | + curl -s -X POST "$FEISHU_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg text "$MSG_CONTENT" '{msg_type: "text", content: {text: $text}}')" + + - name: Send to QQ group + if: steps.msg.outputs.skip != 'true' + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + HOST: ${{ secrets.DEPLOY_HOST }} + ASTRBOT_API_KEY: ${{ secrets.ASTRBOT_API_KEY }} + MSG_CONTENT: ${{ steps.msg.outputs.msg }} + # AI_ANALYSIS: ${{ steps.ai.outputs.analysis }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null + + # if [ -n "$AI_ANALYSIS" ]; then + # MSG_CONTENT="${MSG_CONTENT} + # + # 🤖 AI 分析: + # ${AI_ANALYSIS}" + # fi + + MSG=$(echo "$MSG_CONTENT" | jq -Rs .) + ssh "root@${HOST}" "curl -s -X POST http://localhost:6185/api/v1/im/message \ + -H 'Authorization: Bearer ${ASTRBOT_API_KEY}' \ + -H 'Content-Type: application/json' \ + -d '{\"umo\": \"default:GroupMessage:1087880322\", \"message\": ${MSG}}'" diff --git a/.github/workflows/project-triage.yml b/.github/workflows/project-triage.yml new file mode 100644 index 0000000..cd95e2a --- /dev/null +++ b/.github/workflows/project-triage.yml @@ -0,0 +1,48 @@ +name: Project Triage + +on: + issues: + types: [opened, reopened, labeled, unlabeled, assigned, unassigned] + workflow_dispatch: + inputs: + backfill: + description: Sync all open issues into DBX Issue Triage + required: false + default: "false" + +permissions: + contents: read + issues: read + +jobs: + sync-project-triage: + runs-on: ubuntu-latest + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }} + PROJECT_OWNER: t8y2 + PROJECT_NUMBER: "1" + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + + - name: Validate project token + run: | + if [ -z "$PROJECT_TOKEN" ]; then + echo "Missing PROJECT_AUTOMATION_TOKEN secret" + exit 1 + fi + + - name: Sync current issue + if: ${{ github.event_name == 'issues' }} + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_EVENT_ACTION: ${{ github.event.action }} + run: node .github/scripts/sync-project-triage.mjs --issue-number "$ISSUE_NUMBER" --event-action "$ISSUE_EVENT_ACTION" + + - name: Backfill open issues + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.backfill == 'true' }} + run: node .github/scripts/sync-project-triage.mjs --backfill true diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 0000000..ece7a22 --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,382 @@ +name: Publish Packages + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.3.10)" + required: true + +permissions: + contents: write + +env: + APP_NAME: dbx + PRODUCT_NAME: DBX + REPO_OWNER: t8y2 + +jobs: + publish: + name: Publish to Homebrew & Scoop + runs-on: ubuntu-latest + steps: + - name: Extract version from tag + id: version + run: | + TAG="${{ github.event.inputs.tag }}" + TAG="$(echo "${TAG}" | xargs)" + VERSION="${TAG#v}" + if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Tag '${TAG}' does not look like a semver version." + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p artifacts + VERSION="${{ steps.version.outputs.version }}" + TAG="${{ steps.version.outputs.tag }}" + + ASSETS=( + "${PRODUCT_NAME}_${VERSION}_aarch64.dmg" + "${PRODUCT_NAME}_${VERSION}_x64.dmg" + "${PRODUCT_NAME}_${VERSION}_x64-setup.exe" + "${PRODUCT_NAME}_${VERSION}_arm64-setup.exe" + ) + + for ASSET in "${ASSETS[@]}"; do + echo "::group::Downloading ${ASSET}" + gh release download "${TAG}" \ + --repo "${{ github.repository }}" \ + --pattern "${ASSET}" \ + --dir artifacts \ + --clobber + echo "::endgroup::" + done + + ls -lh artifacts/ + + - name: Compute SHA256 hashes + id: hashes + run: | + VERSION="${{ steps.version.outputs.version }}" + + compute_hash() { + local file="$1" name="$2" + if [[ ! -f "${file}" ]]; then + echo "::error::Missing artifact: ${file}" + exit 1 + fi + local hash + hash=$(sha256sum "${file}" | cut -d ' ' -f 1) + echo "${name}=${hash}" >> "$GITHUB_OUTPUT" + echo " ${name}: ${hash}" + } + + echo "SHA256 hashes:" + compute_hash "artifacts/${PRODUCT_NAME}_${VERSION}_aarch64.dmg" "sha_dmg_arm64" + compute_hash "artifacts/${PRODUCT_NAME}_${VERSION}_x64.dmg" "sha_dmg_x64" + compute_hash "artifacts/${PRODUCT_NAME}_${VERSION}_x64-setup.exe" "sha_exe_x64" + compute_hash "artifacts/${PRODUCT_NAME}_${VERSION}_arm64-setup.exe" "sha_exe_arm64" + + - name: Publish Homebrew Cask + env: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + SHA_ARM64="${{ steps.hashes.outputs.sha_dmg_arm64 }}" + SHA_X64="${{ steps.hashes.outputs.sha_dmg_x64 }}" + + git clone --depth 1 \ + "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/homebrew-tap.git" \ + homebrew-tap + cd homebrew-tap + mkdir -p Casks + + cat > Casks/dbx.rb <<'RUBY_EOF' + cask "dbx" do + arch arm: "aarch64", intel: "x64" + + version "__VERSION__" + sha256 arm: "__SHA_ARM64__", + intel: "__SHA_X64__" + + url "https://github.com/__OWNER__/__APP__/releases/download/v#{version}/__PRODUCT___#{version}_#{arch}.dmg", + verified: "github.com/__OWNER__/__APP__/" + name "DBX" + desc "Database management tool" + homepage "https://dbxio.com/" + + depends_on macos: :big_sur + + app "DBX.app" + + zap trash: [ + "~/Library/Application Support/com.dbx.app", + "~/Library/Caches/com.dbx.app", + "~/Library/Logs/com.dbx.app", + "~/Library/Preferences/com.dbx.app.plist", + ] + end + RUBY_EOF + + sed -i 's/^ //' Casks/dbx.rb + + sed -i "s/__VERSION__/${VERSION}/g" Casks/dbx.rb + sed -i "s/__OWNER__/${REPO_OWNER}/g" Casks/dbx.rb + sed -i "s/__APP__/${APP_NAME}/g" Casks/dbx.rb + sed -i "s/__PRODUCT__/${PRODUCT_NAME}/g" Casks/dbx.rb + sed -i "s/__SHA_ARM64__/${SHA_ARM64}/g" Casks/dbx.rb + sed -i "s/__SHA_X64__/${SHA_X64}/g" Casks/dbx.rb + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Casks/dbx.rb + + if git diff --cached --quiet; then + echo "Homebrew Cask is already up to date." + else + git commit -m "dbx ${VERSION}" + git push + echo "::notice::Homebrew Cask updated to ${VERSION}" + fi + + - name: Publish Scoop manifest + env: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + SHA_EXE_X64="${{ steps.hashes.outputs.sha_exe_x64 }}" + SHA_EXE_ARM64="${{ steps.hashes.outputs.sha_exe_arm64 }}" + + git clone --depth 1 \ + "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/scoop-bucket.git" \ + scoop-bucket + cd scoop-bucket + + cat > dbx.json <> "$GITHUB_OUTPUT" + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p assets + gh release download "${{ steps.version.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --dir assets \ + --clobber + ls -lh assets/ + + - name: Rewrite latest.json download URLs to R2 CDN + run: | + TAG="${{ steps.version.outputs.tag }}" + LATEST_JSON="assets/latest.json" + if [ ! -f "$LATEST_JSON" ]; then + echo "::warning::latest.json not found, skipping URL rewrite" + exit 0 + fi + echo "Before:" + jq '.platforms[]?.url // empty' "$LATEST_JSON" + jq --arg tag "$TAG" ' + (.platforms[]?.url?) |= ( + sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/latest/") + + "?v=" + $tag + ) + ' "$LATEST_JSON" > "$LATEST_JSON.tmp" + mv "$LATEST_JSON.tmp" "$LATEST_JSON" + echo "After:" + jq '.platforms[]?.url // empty' "$LATEST_JSON" + + - name: Upload to R2 (versioned) + run: | + aws s3 sync ./assets "s3://${{ secrets.R2_BUCKET_NAME }}/releases/${{ steps.version.outputs.tag }}/" \ + --endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + + - name: Upload to R2 (latest) + run: | + aws s3 sync ./assets "s3://${{ secrets.R2_BUCKET_NAME }}/releases/latest/" \ + --endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" \ + --cache-control "no-cache" \ + --delete + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + + - name: Purge Cloudflare cache + run: | + set -euo pipefail + zone_id="${CLOUDFLARE_ZONE_ID:-}" + if [ -z "$zone_id" ]; then + zone_id="$(curl -fsSL \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://api.cloudflare.com/client/v4/zones?name=dbxio.com" \ + | jq -r '.result[0].id // empty')" + fi + + if [ -z "$zone_id" ]; then + echo "::error::Cloudflare zone dbxio.com not found. Set CLOUDFLARE_ZONE_ID or grant Zone:Read to CLOUDFLARE_API_TOKEN." + exit 1 + fi + + curl -fsSL -X POST \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}' \ + "https://api.cloudflare.com/client/v4/zones/${zone_id}/purge_cache" \ + | jq -e '.success == true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + + promote-release: + name: Promote to Release + needs: [publish, upload-r2] + runs-on: ubuntu-latest + steps: + - name: Promote prerelease to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ github.event.inputs.tag }}" + gh release edit "${TAG}" \ + --repo "${{ github.repository }}" \ + --prerelease=false \ + --latest + echo "::notice::Release ${TAG} promoted from prerelease to latest release" + + notify-qq: + name: QQ Notify + needs: [promote-release] + runs-on: ubuntu-latest + steps: + - name: Build message + id: msg + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.inputs.tag }} + run: | + RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json tagName,name,url,body) + export RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tagName') + export RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.url') + export RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body') + + python3 - <<'PYEOF' + import os, re + + tag = os.environ["RELEASE_TAG"] + url = os.environ["RELEASE_URL"] + body = os.environ.get("RELEASE_BODY", "")[:800] + + body = re.sub(r'#{1,6}\s*', '', body) + body = re.sub(r'\*\*(.+?)\*\*', r'\1', body) + body = re.sub(r'\*(.+?)\*', r'\1', body) + body = re.sub(r'`(.+?)`', r'\1', body) + body = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', body) + body = re.sub(r'\n{3,}', '\n\n', body) + body = body.strip() + + msg = "\n".join([ + "━━━━━━━━━━━━━━━", + f"🚀 新版本发布 {tag}", + "━━━━━━━━━━━━━━━", + "", + body, + "", + f"下载: {url}", + "━━━━━━━━━━━━━━━", + ]) + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + delim = "EOF_MSG" + f.write(f"msg<<{delim}\n{msg}\n{delim}\n") + PYEOF + + - name: Send to QQ group + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + HOST: ${{ secrets.DEPLOY_HOST }} + ASTRBOT_API_KEY: ${{ secrets.ASTRBOT_API_KEY }} + MSG_CONTENT: ${{ steps.msg.outputs.msg }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null + + PAYLOAD=$(jq -nc --arg msg "$MSG_CONTENT" \ + '{"umo": "default:GroupMessage:1087880322", "message": $msg}') + printf '%s' "$PAYLOAD" | ssh "root@${HOST}" "curl -fsS -X POST http://localhost:6185/api/v1/im/message \ + -H 'Authorization: Bearer ${ASTRBOT_API_KEY}' \ + -H 'Content-Type: application/json' \ + --data-binary @-" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..789e415 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,557 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + CNB_REPOSITORY: "dbxio.com/dbx" + CNB_USERNAME: "cnb" + CNB_REGISTRY: "docker.cnb.cool" + +jobs: + bump-jdbc-plugin-version: + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.bump.outputs.changed }} + old_version: ${{ steps.bump.outputs.old_version }} + new_version: ${{ steps.bump.outputs.new_version }} + prev_tag: ${{ steps.prev-tag.outputs.prev_tag }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Find previous release tag + id: prev-tag + shell: bash + run: | + PREV_TAG=$(git tag --sort=-creatordate | grep '^v' | sed -n '2p') + if [ -z "$PREV_TAG" ]; then + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + fi + echo "prev_tag=${PREV_TAG}" >> "$GITHUB_OUTPUT" + echo "Comparing ${PREV_TAG}..HEAD" + + - name: Detect JDBC plugin changes and bump version + id: bump + shell: bash + run: | + BUMP_OUTPUT="$(node .github/scripts/bump-jdbc-plugin-version.mjs "${{ steps.prev-tag.outputs.prev_tag }}" HEAD --write)" + echo "$BUMP_OUTPUT" + echo "$BUMP_OUTPUT" >> "$GITHUB_OUTPUT" + + - name: Commit JDBC plugin version bump + if: steps.bump.outputs.changed == 'true' + shell: bash + run: | + TMP_DIR="$(mktemp -d)" + cp plugins/jdbc/pom.xml "$TMP_DIR/pom.xml" + cp plugins/jdbc/manifest.json "$TMP_DIR/manifest.json" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin main + git switch -C jdbc-plugin-version-bump origin/main + cp "$TMP_DIR/pom.xml" plugins/jdbc/pom.xml + cp "$TMP_DIR/manifest.json" plugins/jdbc/manifest.json + git add plugins/jdbc/pom.xml plugins/jdbc/manifest.json + if git diff --cached --quiet; then + echo "JDBC plugin version ${{ steps.bump.outputs.new_version }} is already on main." + exit 0 + fi + git commit -m "chore(jdbc): bump plugin version [skip ci]" + git push origin HEAD:main + + build: + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest + target: aarch64-apple-darwin + - platform: macos-15-intel + target: x86_64-apple-darwin + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - platform: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + - platform: windows-2022 + target: x86_64-pc-windows-msvc + arch: x64 + - platform: windows-2022 + target: aarch64-pc-windows-msvc + arch: arm64 + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Install frontend dependencies + run: pnpm install + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Compute Rust dependency hash + id: deps-hash + shell: bash + run: | + { + find . -name Cargo.toml -not -path './target/*' -print0 \ + | sort -z \ + | xargs -0 sed -E '/^version = /d' + grep -v '^version = ' Cargo.lock + } | sha256sum | cut -d' ' -f1 | { + read -r hash + echo "hash=${hash:0:20}" >> "$GITHUB_OUTPUT" + } + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./ -> target" + shared-key: release-${{ matrix.target }}-${{ steps.deps-hash.outputs.hash }} + add-rust-environment-hash-key: false + cache-on-failure: true + + - name: Install Apple certificate (macOS) + if: startsWith(matrix.platform, 'macos') + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH + security create-keychain -p "" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "" $KEYCHAIN_PATH + security import $CERTIFICATE_PATH -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "" $KEYCHAIN_PATH + security list-keychains -d user -s $KEYCHAIN_PATH login.keychain-db + + - name: Install system dependencies (Linux) + if: startsWith(matrix.platform, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils + + - name: Setup Tauri signing key + shell: bash + run: | + echo "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/updater.key" + KEY_B64=$(base64 < "$RUNNER_TEMP/updater.key" | tr -d '\r\n') + echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV" + if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then + echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> "$GITHUB_ENV" + fi + + - name: Generate release notes + id: release-notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BODY="$(gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="${GITHUB_REF_NAME}" \ + --jq '.body')" + if [ -z "$BODY" ]; then + BODY="DBX ${GITHUB_REF_NAME}" + fi + DELIM="RELEASE_NOTES_$(date +%s%N)" + { + echo "body<<$DELIM" + printf '%s\n' "$BODY" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_SIGNING_IDENTITY: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_SIGNING_IDENTITY || '' }} + APPLE_ID: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_ID || '' }} + APPLE_PASSWORD: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_TEAM_ID || '' }} + with: + tagName: ${{ github.ref_name }} + releaseName: "DBX ${{ github.ref_name }}" + releaseBody: ${{ steps.release-notes.outputs.body }} + releaseDraft: true + prerelease: false + args: --target ${{ matrix.target }} + + - name: Upload Windows portable ZIP + if: matrix.arch == 'x64' || matrix.arch == 'arm64' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = "${env:GITHUB_REF_NAME}".TrimStart("v") + $arch = "${{ matrix.arch }}" + $portableRoot = "portable" + $portableDir = Join-Path $portableRoot "DBX_${version}_${arch}" + $zipName = "DBX_${version}_${arch}-portable.zip" + $exePath = "target/${{ matrix.target }}/release/dbx.exe" + + if (!(Test-Path $exePath)) { + Write-Error "Missing Windows executable: $exePath" + exit 1 + } + + New-Item -ItemType Directory -Force -Path $portableDir | Out-Null + Copy-Item $exePath (Join-Path $portableDir "DBX.exe") -Force + Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force + Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force + Set-Content -Path (Join-Path $portableDir "portable.dbx") -Value "" -NoNewline + + Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath $zipName -Force + gh release upload "${env:GITHUB_REF_NAME}" $zipName --repo "${env:GITHUB_REPOSITORY}" --clobber + + - name: Upload Windows WebView2 offline installer + if: matrix.arch == 'x64' || matrix.arch == 'arm64' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = "${env:GITHUB_REF_NAME}".TrimStart("v") + $arch = "${{ matrix.arch }}" + $bundleDir = "target/${{ matrix.target }}/release/bundle/nsis" + $offlineName = "DBX_${version}_${arch}-webview2-offline-setup.exe" + $before = @{} + + if (Test-Path $bundleDir) { + Get-ChildItem $bundleDir -Filter "*.exe" | ForEach-Object { $before[$_.FullName] = $_.LastWriteTimeUtc } + } + + pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} --config src-tauri/tauri.webview2-offline.conf.json + + $installer = Get-ChildItem $bundleDir -Filter "*.exe" | + Where-Object { !$before.ContainsKey($_.FullName) -or $_.LastWriteTimeUtc -gt $before[$_.FullName] } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if (!$installer) { + Write-Error "Missing WebView2 offline installer in ${bundleDir}" + exit 1 + } + + Copy-Item $installer.FullName $offlineName -Force + gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber + + cleanup-release-signatures: + needs: build + runs-on: ubuntu-latest + steps: + - name: Remove standalone updater signature assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + mapfile -t SIG_ASSETS < <( + gh release view "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json assets \ + --jq '.assets[].name | select(endswith(".sig"))' + ) + + if [ "${#SIG_ASSETS[@]}" -eq 0 ]; then + echo "No standalone .sig release assets found." + exit 0 + fi + + for ASSET in "${SIG_ASSETS[@]}"; do + echo "Deleting release asset: ${ASSET}" + gh release delete-asset "${GITHUB_REF_NAME}" "${ASSET}" \ + --repo "${GITHUB_REPOSITORY}" \ + --yes + done + + jdbc-plugin: + needs: [cleanup-release-signatures, bump-jdbc-plugin-version] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: maven + + - name: Apply automatic JDBC plugin version bump + shell: bash + run: node .github/scripts/bump-jdbc-plugin-version.mjs "${{ needs.bump-jdbc-plugin-version.outputs.prev_tag }}" HEAD --write + + - name: Read JDBC plugin version + id: jdbc-plugin + shell: bash + run: | + VERSION="$(grep -m1 '' plugins/jdbc/pom.xml | sed -E 's/.*([^<]+)<.*/\1/')" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Package JDBC plugin + run: ./plugins/jdbc/package.sh + + - name: Upload JDBC plugin asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${GITHUB_REF_NAME}" \ + "plugins/jdbc/dist/dbx-jdbc-plugin-${{ steps.jdbc-plugin.outputs.version }}.zip" \ + "plugins/jdbc/dist/dbx-jdbc-plugin-latest.zip" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + + - name: Add JDBC plugin metadata to latest.json + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/latest-json" + gh release download "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern latest.json \ + --dir "$RUNNER_TEMP/latest-json" + node .github/scripts/augment-latest-json-jdbc-plugin.mjs \ + "$RUNNER_TEMP/latest-json/latest.json" \ + "${{ steps.jdbc-plugin.outputs.version }}" \ + "1" \ + "https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip" + gh release upload "${GITHUB_REF_NAME}" \ + "$RUNNER_TEMP/latest-json/latest.json" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + + publish: + needs: [cleanup-release-signatures, docker-manifest, jdbc-plugin] + runs-on: ubuntu-latest + steps: + - name: Publish draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit ${{ github.ref_name }} --repo ${{ github.repository }} --draft=false --prerelease + + sync-release-to-cnb: + name: Sync release to CNB + needs: publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Download GitHub release assets + env: + TAG_NAME: ${{ github.ref_name }} + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" \ + --clobber + + - name: Sync release assets to CNB + env: + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + CNB_UPLOAD_CONCURRENCY: "3" + run: | + node .github/scripts/sync-cnb-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets" + + sync-release-to-atomgit: + name: Sync release to AtomGit + needs: publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Ensure AtomGit tag exists + env: + TAG_NAME: ${{ github.ref_name }} + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + run: | + set -euo pipefail + git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + + - name: Download GitHub release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" \ + --clobber + + - name: Sync release assets to AtomGit + env: + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + ATOMGIT_REPOSITORY: "t8y2/dbx" + # AtomGit throttles concurrent large-asset uploads, which can expire their temporary upload URLs. + ATOMGIT_UPLOAD_CONCURRENCY: "1" + run: | + set -euo pipefail + node .github/scripts/sync-atomgit-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets" + + docker: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: [linux/amd64, linux/arm64] + steps: + - uses: actions/checkout@v5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to CNB Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.CNB_REGISTRY }} + username: cnb + password: ${{ secrets.CNB_TOKEN }} + + - name: Build and push Docker Hub by digest + id: build-dockerhub + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/dbx,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,scope=${{ matrix.platform }},mode=max + + - name: Build and push CNB by digest + id: build-cnb + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + + - name: Export digests + run: | + mkdir -p /tmp/digests/dockerhub /tmp/digests/cnb + dockerhub_digest="${{ steps.build-dockerhub.outputs.digest }}" + cnb_digest="${{ steps.build-cnb.outputs.digest }}" + touch "/tmp/digests/dockerhub/${dockerhub_digest#sha256:}" + touch "/tmp/digests/cnb/${cnb_digest#sha256:}" + + - name: Upload Docker Hub digest + uses: actions/upload-artifact@v7 + with: + name: dockerhub-digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/dockerhub/* + if-no-files-found: error + retention-days: 1 + + - name: Upload CNB digest + uses: actions/upload-artifact@v7 + with: + name: cnb-digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/cnb/* + if-no-files-found: error + retention-days: 1 + + docker-manifest: + needs: docker + runs-on: ubuntu-latest + steps: + - name: Download Docker Hub digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests/dockerhub + pattern: dockerhub-digests-* + merge-multiple: true + + - name: Download CNB digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests/cnb + pattern: cnb-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to CNB Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.CNB_REGISTRY }} + username: cnb + password: ${{ secrets.CNB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Create manifest list and push + run: | + docker buildx imagetools create \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/dbx:${{ steps.version.outputs.version }} \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/dbx:latest \ + $(cd /tmp/digests/dockerhub && printf '${{ secrets.DOCKERHUB_USERNAME }}/dbx@sha256:%s ' *) + + docker buildx imagetools create \ + -t ${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:${{ steps.version.outputs.version }} \ + -t ${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:latest \ + $(cd /tmp/digests/cnb && printf '${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}@sha256:%s ' *) diff --git a/.github/workflows/spam-comment-guard.yml b/.github/workflows/spam-comment-guard.yml new file mode 100644 index 0000000..0567844 --- /dev/null +++ b/.github/workflows/spam-comment-guard.yml @@ -0,0 +1,41 @@ +name: Spam Comment Guard + +on: + issue_comment: + types: [created] + +# 用 PROJECT_AUTOMATION_TOKEN(owner PAT)调 DELETE,GITHUB_TOKEN 本身无需写权限 +permissions: + contents: read + +jobs: + delete-malicious-link: + runs-on: ubuntu-latest + # 跳过机器人与仓库 owner,避免误删维护者的正当评论 + if: ${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner }} + env: + # owner PAT:GITHUB_TOKEN 非 admin 删不掉别人的评论,必须用 owner 令牌 + GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_USER: ${{ github.event.comment.user.login }} + ISSUE_URL: ${{ github.event.issue.html_url }} + REPO: ${{ github.repository }} + steps: + - name: Delete comment containing malicious attachment link + run: | + set -euo pipefail + + # 命中外部恶意附件链接即删除评论。两条分支: + # 1) 外部 http(s) 直链 .zip(排除 GitHub 自有域名的 release/archive/raw/attachments) + # 2) markdown 链接文本以 .zip/.exe/.scr/.7z 结尾且指向外部域名—— + # 规避手法常把 .zip 放链接文本、URL 用 // 协议相对或 %2E 编码域名 + # 如需扩展扩展名,修改下方两处列表即可。 + if ! printf '%s' "$COMMENT_BODY" | grep -qiP 'https?://(?!(?:github\.com|[^/]*\.githubusercontent\.com)/)[^[:space:]<>"]+\.zip([?#][^[:space:]<>"]*)?|\[[^\]]*\.(zip|7z|exe|scr)\]\((?!(?:https?:)?//(?:github\.com|[^/]*\.githubusercontent\.com)/)[^)]*\)'; then + echo "No malicious attachment link detected, skipping" + exit 0 + fi + + echo "Detected malicious attachment link in comment by @$COMMENT_USER on $ISSUE_URL — deleting" + # 物理删除评论,内容对所有人不可见。DELETE 返回 204 无响应体。 + gh api -X DELETE "repos/${REPO}/issues/comments/${COMMENT_ID}" diff --git a/.github/workflows/sync-atomgit-release-assets.yml b/.github/workflows/sync-atomgit-release-assets.yml new file mode 100644 index 0000000..4c6e87c --- /dev/null +++ b/.github/workflows/sync-atomgit-release-assets.yml @@ -0,0 +1,62 @@ +name: Sync AtomGit Release Assets + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to sync" + required: true + type: string + +permissions: + contents: read + +env: + ATOMGIT_REPOSITORY: "t8y2/dbx" + +jobs: + sync-release-to-atomgit: + name: Sync release to AtomGit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Ensure AtomGit tag exists + env: + TAG_NAME: ${{ inputs.tag }} + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + run: | + set -euo pipefail + git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + + - name: Download GitHub release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ inputs.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" \ + --clobber + + - name: Sync release assets to AtomGit + env: + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + ATOMGIT_REPOSITORY: ${{ env.ATOMGIT_REPOSITORY }} + run: | + set -euo pipefail + node .github/scripts/sync-atomgit-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets" diff --git a/.github/workflows/sync-changelog.yml b/.github/workflows/sync-changelog.yml new file mode 100644 index 0000000..009d02d --- /dev/null +++ b/.github/workflows/sync-changelog.yml @@ -0,0 +1,46 @@ +name: Sync Changelog to R2 + +on: + workflow_run: + workflows: ["Publish Packages"] + types: [completed] + workflow_dispatch: + +jobs: + sync: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Generate changelog JSON + run: node .github/scripts/sync-changelog.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + + - name: Upload to R2 + run: | + pip install awscli --quiet + aws s3 cp releases-cn.json "s3://${R2_BUCKET_NAME}/changelog/releases-cn.json" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + if [ -f releases-en.json ]; then + aws s3 cp releases-en.json "s3://${R2_BUCKET_NAME}/changelog/releases-en.json" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + fi + if [ -f latest-en.json ]; then + aws s3 cp latest-en.json "s3://${R2_BUCKET_NAME}/changelog/latest-en.json" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + fi + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} diff --git a/.github/workflows/sync-cnb-release-assets.yml b/.github/workflows/sync-cnb-release-assets.yml new file mode 100644 index 0000000..ddea29b --- /dev/null +++ b/.github/workflows/sync-cnb-release-assets.yml @@ -0,0 +1,40 @@ +name: Sync CNB Release Assets + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to sync" + required: true + type: string + +permissions: + contents: read + +env: + CNB_REPOSITORY: "dbxio.com/dbx" + +jobs: + sync-release-to-cnb: + name: Sync release to CNB + runs-on: ubuntu-latest + steps: + - name: Sync release assets to CNB + env: + TAG_NAME: ${{ inputs.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_USERNAME: ${{ github.repository_owner }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CNB_USERNAME: cnb + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + run: | + set -euo pipefail + + wget -q https://cnb.cool/znb/mpgrm/-/releases/download/v0.1.0/mpgrm_linux_amd64.tar.gz + tar -xf mpgrm_linux_amd64.tar.gz + chmod +x mpgrm + + ./mpgrm releases sync \ + --repo "https://github.com/${GITHUB_REPOSITORY}.git" \ + --target-repo "https://cnb.cool/${CNB_REPOSITORY}.git" \ + --tags "${TAG_NAME}" diff --git a/.github/workflows/sync-mirrors.yml b/.github/workflows/sync-mirrors.yml new file mode 100644 index 0000000..3dfd21e --- /dev/null +++ b/.github/workflows/sync-mirrors.yml @@ -0,0 +1,80 @@ +name: Sync Mirrors + +on: + push: + branches: + - main + tags: + - "**" + delete: + workflow_dispatch: + +# Mirror providers reject concurrent forced updates to the same ref. Serialize all +# branch, tag, and delete events so rapid GitHub pushes cannot race each other. +concurrency: + group: sync-mirrors + cancel-in-progress: false + +jobs: + sync-cnb: + name: Sync to CNB + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Push to CNB + env: + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + run: | + set -euo pipefail + git remote add cnb "https://cnb:${CNB_TOKEN}@cnb.cool/dbxio.com/dbx.git" + git push cnb --all --force + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + git push cnb ":refs/tags/${GITHUB_REF_NAME}" || true + fi + git push cnb --tags --force + + sync-atomgit: + name: Sync to AtomGit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Push to AtomGit + env: + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + run: | + set -euo pipefail + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit --all --force + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + git push atomgit ":refs/tags/${GITHUB_REF_NAME}" || true + fi + git push atomgit --tags --force + + sync-gitee: + name: Sync to Gitee + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Push to Gitee + env: + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + run: | + set -euo pipefail + git remote add gitee "https://codetty:${GITEE_TOKEN}@gitee.com/codetty/dbx.git" + git push gitee --all --force + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + git push gitee ":refs/tags/${GITHUB_REF_NAME}" || true + fi + git push gitee --tags --force diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..880af71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Dependencies +node_modules/ +.claude/ +.codex/ +.superpowers/ +.trellis/ +.worktrees/ +openspec/ +.mcp.json +AGENTS.md +CLAUDE.md + +# Build output +dist/ +coverage/ +/target/ +plugins/jdbc/target/ +plugins/jdbc/dependency-reduced-pom.xml +plugins/jdbc/lib/*.jar +plugins/jdbc/dist/ +docs/.next/ +docs/out/ +docs/.source/ +docs/superpowers/ +src-tauri/*.dylib + +# OS files +.DS_Store +Thumbs.db + +# Editor +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# GitHub (keep workflows) +.github/* +!.github/workflows/ +!.github/scripts/ +!.github/ISSUE_TEMPLATE/ + +# Env +.env +.env.* +!.env.example + +# Temp +tmp/ + +# Logs +*.log + + +# Generated changelog data +releases-*.json +latest-en.json + +test.pdb +portable/ +DBX_*_x64-portable.zip +.agents/skills +.pnpm-store/* +.dbx-web +_dev diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..a8d5342 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,20 @@ +pnpm exec lint-staged + +# Only format and re-add Rust files that are already staged +TOPLEVEL=$(git rev-parse --show-toplevel) + +format_staged_rust() { + dir="$1" + staged=$(git diff --staged --name-only --diff-filter=ACM -- "$dir" | grep '\.rs$' || true) + if [ -n "$staged" ]; then + echo "Formatting staged Rust files in $dir" + cargo fmt --manifest-path "$TOPLEVEL/$dir/Cargo.toml" + echo "$staged" | while IFS= read -r f; do + git add "$TOPLEVEL/$f" 2>/dev/null || true + done + fi +} + +format_staged_rust "src-tauri" +format_staged_rust "crates/dbx-core" +format_staged_rust "crates/dbx-web" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..6fa8dec --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.13.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..660b096 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 300, + "tabWidth": 2, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..959b663 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,143 @@ +# Contributing to DBX + +Thanks for taking a look at DBX. Whether you fix a typo, improve docs, or tackle a database-specific bug, every PR helps. + +## Where to Start + +1. Browse [open issues](https://github.com/t8y2/dbx/issues). Good first targets are labeled `documentation`, `good first issue`, or issues in a database you already use. +2. Comment on the issue you want to work on. Say what you plan to do so others do not duplicate the effort. For community issues, you can also comment `/claim` when that workflow is enabled. +3. Fork the repo, create a branch, and open a PR against `main`. + +If you are not sure what to pick, documentation and small UX fixes are a solid first contribution. See [examples/](examples/) and the [official docs](https://dbxio.com/en/docs/what-is-dbx) for the current structure. + +## Development Setup + +### Prerequisites + +- Node.js >= 22.13.0 +- pnpm 10.27.0 +- Rust >= 1.77 +- Make + +Linux desktop builds also need WebKit/GTK packages. See [README.md](README.md#getting-started) for the exact commands. + +### Run Locally + +```bash +git clone https://github.com/t8y2/dbx.git +cd dbx +make +``` + +`make` installs dependencies when needed and starts the Tauri desktop dev environment. + +Useful shortcuts: + +```bash +make dev-fast # skip DuckDB during local dev +make dev-web # frontend only +make dev-backend # web backend only +make docs # preview the documentation site +make cargo-check-fast # fast Rust checks +``` + +### JDBC Agent Drivers + +Agent driver projects live under `agents/`. Java/JDBC driver builds and tests require JDK 21; Gradle can auto-download the toolchain when available. + +```bash +cd agents +./gradlew test +``` + +## Project Layout + +| Path | Purpose | +| --- | --- | +| `src/` | Vue frontend | +| `src-tauri/` | Tauri desktop shell and command layer | +| `crates/dbx-core/` | Shared Rust database logic | +| `crates/dbx-web/` | Docker / Web HTTP backend | +| `packages/cli/` | `@dbx-app/cli` | +| `packages/mcp-server/` | `@dbx-app/mcp-server` | +| `packages/node-core/` | Shared Node.js bridge and direct-query logic | +| `docs/` | Official documentation site | +| `examples/` | Sample configs and automation scripts | +| `agents/` | JDBC agent driver projects | + +## Making Changes + +### Branch Naming + +Use a short descriptive branch name, for example: + +- `docs/web-api-reference` +- `fix/mysql-connection-timeout` +- `feat/redis-key-search` + +### Scope + +Keep PRs focused. A docs-only PR should not include unrelated code changes. A bug fix should not also refactor nearby modules unless that refactor is required for the fix. + +### Commits + +Write commit messages in plain language: + +- `docs: add web API reference for Docker deployments` +- `fix(redis): handle empty scan cursor` +- `feat(schema): show catalog info for Doris` + +### Tests + +Run the checks that match your change: + +```bash +make cargo-check-fast +make cargo-test-fast +pnpm test +``` + +For frontend or package changes, run the relevant package tests under `packages/` or `packages/app-tests/`. + +### Documentation + +User-facing docs live in two places: + +- Repository docs: `README.md`, `CONTRIBUTING.md`, package READMEs, and `examples/` +- Website docs: `docs/content/docs/` + +If you add a new docs page under `docs/content/docs/`, register it in: + +- `docs/content/docs/meta.json` +- `docs/content/docs/meta.cn.json` + +Preview locally with: + +```bash +make docs +``` + +## Pull Requests + +1. Push your branch to your fork. +2. Open a PR against `https://github.com/t8y2/dbx` `main`. +3. Link the related issue in the PR description. +4. Explain what changed, how you tested it, and any screenshots if the UI changed. + +Small PRs are easier to review and merge. + +## What We Are Looking For + +- Documentation improvements and translations +- Reproducible bug fixes with clear before/after behavior +- Database-specific fixes where you can verify against a real instance +- Tests for non-trivial logic changes +- Examples that show CLI, MCP, Docker, or Web API usage + +## Community + +- [Discord](https://discord.gg/W7NyVDRt6a) +- [GitHub Issues](https://github.com/t8y2/dbx/issues) +- [Official docs](https://dbxio.com/en/docs/what-is-dbx) + +Merged contributors appear on the [DBX contributors wall](https://dbxio.com/en/community). diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md new file mode 100644 index 0000000..29f398a --- /dev/null +++ b/CONTRIBUTING.zh-CN.md @@ -0,0 +1,143 @@ +# 参与 DBX 贡献 + +感谢你愿意花时间了解 DBX。不管是改一个错别字、补文档,还是修某个数据库相关的问题,都很有价值。 + +## 从哪里开始 + +1. 浏览 [Issues](https://github.com/t8y2/dbx/issues)。优先看 `documentation`、`good first issue`,或者你手头正好有测试环境的数据库相关问题。 +2. 在 Issue 下留言说明你想做什么,避免重复劳动。部分社区 Issue 也支持评论 `/claim` 来认领。 +3. Fork 仓库,新建分支开发,然后向 `main` 提 PR。 + +如果暂时不确定做什么,文档和小体验修复很适合第一次贡献。可以先看 [examples/](examples/) 和[官方文档](https://dbxio.com/cn/docs/what-is-dbx) 熟悉项目结构。 + +## 开发环境 + +### 环境要求 + +- Node.js >= 22.13.0 +- pnpm 10.27.0 +- Rust >= 1.77 +- Make + +Linux 桌面端还需要 WebKit/GTK 相关依赖,具体命令见 [README.zh-CN.md](README.zh-CN.md#快速开始)。 + +### 本地运行 + +```bash +git clone https://github.com/t8y2/dbx.git +cd dbx +make +``` + +`make` 会在需要时安装依赖,并启动 Tauri 桌面端开发环境。 + +常用命令: + +```bash +make dev-fast # 本地开发跳过 DuckDB +make dev-web # 只启动前端 +make dev-backend # 只启动 Web 后端 +make docs # 本地预览文档站 +make cargo-check-fast # 快速 Rust 检查 +``` + +### JDBC Agent 驱动 + +Agent 驱动工程在 `agents/` 目录。Java/JDBC 驱动构建和测试需要 JDK 21;环境允许时 Gradle 可以自动下载对应 toolchain。 + +```bash +cd agents +./gradlew test +``` + +## 项目结构 + +| 路径 | 说明 | +| --- | --- | +| `src/` | Vue 前端 | +| `src-tauri/` | Tauri 桌面端壳层与命令层 | +| `crates/dbx-core/` | 共享 Rust 数据库逻辑 | +| `crates/dbx-web/` | Docker / Web HTTP 后端 | +| `packages/cli/` | `@dbx-app/cli` | +| `packages/mcp-server/` | `@dbx-app/mcp-server` | +| `packages/node-core/` | Node.js bridge 与直连查询逻辑 | +| `docs/` | 官方文档站 | +| `examples/` | 配置与自动化示例 | +| `agents/` | JDBC Agent 驱动工程 | + +## 开发约定 + +### 分支命名 + +分支名尽量简短明确,例如: + +- `docs/web-api-reference` +- `fix/mysql-connection-timeout` +- `feat/redis-key-search` + +### 控制改动范围 + +一个 PR 只做一类事。文档 PR 不要夹带无关代码;修 Bug 时也不要顺手大重构,除非重构是修复所必需的。 + +### 提交说明 + +提交信息用自然语言写清楚即可: + +- `docs: add web API reference for Docker deployments` +- `fix(redis): handle empty scan cursor` +- `feat(schema): show catalog info for Doris` + +### 测试 + +按改动范围跑对应检查: + +```bash +make cargo-check-fast +make cargo-test-fast +pnpm test +``` + +如果改的是前端或某个 package,再补跑对应目录下的测试。 + +### 文档 + +用户文档主要分两块: + +- 仓库内文档:`README.md`、`CONTRIBUTING.md`、各 package README、`examples/` +- 官网文档:`docs/content/docs/` + +如果在 `docs/content/docs/` 新增页面,记得同步更新: + +- `docs/content/docs/meta.json` +- `docs/content/docs/meta.cn.json` + +本地预览: + +```bash +make docs +``` + +## 提 PR + +1. 把分支推到你自己的 Fork。 +2. 向 `https://github.com/t8y2/dbx` 的 `main` 提 PR。 +3. 在 PR 描述里关联相关 Issue。 +4. 写清楚改了什么、怎么验证的;如果涉及 UI,附上截图。 + +改动越小,越容易 review 和合并。 + +## 欢迎的贡献类型 + +- 文档改进与翻译 +- 可复现、行为清晰的 Bug 修复 +- 你有真实测试环境的数据库专项修复 +- 非平凡逻辑的测试补充 +- CLI、MCP、Docker、Web API 的使用示例 + +## 社区 + +- [Discord](https://discord.gg/W7NyVDRt6a) +- [GitHub Issues](https://github.com/t8y2/dbx/issues) +- [官方文档](https://dbxio.com/cn/docs/what-is-dbx) + +合并后的贡献者会出现在 [DBX 贡献墙](https://dbxio.com/cn/community)。 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..10c7ac6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,10781 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" +dependencies = [ + "aead 0.6.0-rc.10", + "aes 0.9.0", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arrow" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atoi_simd" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf" +dependencies = [ + "debug_unsafe", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.6", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2 0.12.2", + "sha2 0.10.9", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bson" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" +dependencies = [ + "ahash 0.8.12", + "base64 0.22.1", + "bitvec", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "indexmap 2.14.0", + "js-sys", + "once_cell", + "rand 0.9.4", + "serde", + "serde_bytes", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "btoi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calamine" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a9acfeb1555aa8def91fe8ff208aadaea850c109968ec35ac965edbe7d210b" +dependencies = [ + "atoi_simd", + "byteorder", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml 0.37.5", + "serde", + "zip 4.6.1", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.7.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96dacf199529fb801ae62a9aafdc01b189e9504c0d1ee1512a4c16bcd8666a93" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.0-pre.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6081ce8b60c0e533e2bba42771b94eb6149052115f4179744d5779883dc98583" +dependencies = [ + "crypto-bigint", + "libm", + "rand_core 0.10.1", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbx" +version = "0.5.55" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "calamine", + "chrono", + "csv", + "dbx-core", + "deadpool-postgres", + "duckdb", + "font-kit", + "futures", + "libloading 0.8.9", + "log", + "mongodb", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "percent-encoding", + "portpicker", + "redis", + "reqwest 0.12.28", + "russh", + "rust_decimal", + "rustls 0.23.40", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-log", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tiberius", + "tokio", + "tokio-postgres", + "tokio-util", + "uuid", + "zip 4.6.1", +] + +[[package]] +name = "dbx-core" +version = "0.1.0" +dependencies = [ + "aes-gcm 0.10.3", + "anyhow", + "argon2", + "async-trait", + "base64 0.22.1", + "bytes", + "calamine", + "chrono", + "csv", + "deadpool-postgres", + "duckdb", + "encoding_rs", + "font-kit", + "futures", + "iana-time-zone", + "jsonwebtoken", + "log", + "mongodb", + "mysql_async", + "pageant", + "percent-encoding", + "portpicker", + "rayon", + "redis", + "regex", + "reqwest 0.12.28", + "rusqlite", + "russh", + "rust_decimal", + "rustls 0.23.40", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlparser", + "sysinfo", + "tiberius", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tokio-util", + "uuid", + "webpki-roots 0.26.11", + "zip 4.6.1", +] + +[[package]] +name = "dbx-web" +version = "0.5.55" +dependencies = [ + "aes-gcm 0.10.3", + "argon2", + "async-stream", + "axum", + "base64 0.22.1", + "chrono", + "dbx-core", + "futures", + "log", + "pbkdf2 0.12.2", + "redis", + "reqwest 0.12.28", + "rustls 0.23.40", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "duckdb" +version = "1.10503.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cb42b21e3be42ef14acda15ce8dc99c125ce5376b87c8016a432cf14ae65de1" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink 0.10.0", + "libduckdb-sys", + "num-integer", + "rust_decimal", + "strum", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.17.0-rc.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91bbdd377139884fafcad8dc43a760a3e1e681aa26db910257fa6535b70e1829" +dependencies = [ + "der 0.8.0", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki 0.8.0-rc.4", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" +dependencies = [ + "curve25519-dalek 5.0.0-pre.6", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.14.0-rc.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde7860544606d222fd6bd6d9f9a0773321bf78072a637e1d560a058c0031978" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "hkdf", + "hybrid-array", + "once_cell", + "pem-rfc7468 1.0.0", + "pkcs8", + "rand_core 0.10.1", + "rustcrypto-ff", + "rustcrypto-group", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "generic-array" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.1", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.40", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry 0.6.1", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.18+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f8a978272e3cbdf4768f7363eb1c8e1e6ba63c52a3ed05e29e222da4aec7cb" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "crypto-bigint", + "ecdsa", + "ed25519-dalek", + "hex", + "hmac 0.13.0", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "internal-russh-num-bigint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.1", + "rand_core 0.10.1", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry 0.6.1", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap 2.14.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libduckdb-sys" +version = "1.10503.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a4389c243e7afa0fbc8d8471031335ddc8a2dac4534f3290c9c2ba3edabab5" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "reqwest 0.12.28", + "serde", + "serde_json", + "tar", + "vcpkg", + "zip 6.0.0", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro_magic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" +dependencies = [ + "macro_magic_core", + "macro_magic_macros", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_magic_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" +dependencies = [ + "const-random", + "derive-syn-parse", + "macro_magic_core_macros", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_magic_core_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_magic_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" +dependencies = [ + "macro_magic_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.3.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8198b5db27ac9773534c371751a59dc18aec8b80aa141e69abfdd1dec2e3f78c" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mongocrypt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" +dependencies = [ + "bson", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.5+1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" + +[[package]] +name = "mongodb" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276ba0cd571553d1f6936c6f180964776ece6ab7507dc8765f8a9c9c49d8cd00" +dependencies = [ + "base64 0.22.1", + "bitflags 2.11.1", + "bson", + "derive-where", + "derive_more", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hickory-net", + "hickory-proto", + "hickory-resolver", + "hmac 0.12.1", + "macro_magic", + "md-5 0.10.6", + "mongocrypt", + "mongodb-internal-macros", + "pbkdf2 0.12.2", + "percent-encoding", + "rand 0.9.4", + "rustc_version_runtime", + "rustls 0.23.40", + "serde", + "serde_bytes", + "serde_with", + "sha1 0.10.6", + "sha2 0.10.9", + "socket2 0.6.3", + "stringprep", + "strsim", + "take_mut", + "thiserror 2.0.18", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "typed-builder", + "uuid", + "webpki-roots 1.0.7", +] + +[[package]] +name = "mongodb-internal-macros" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ceb1a9a1018e470077ec94cf3a8c2d0e6da542b2c05ea95a59a0a627147375" +dependencies = [ + "macro_magic", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "mysql-common-derive" +version = "0.32.1" +source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=5b30d4912ebb23728f1196b2b9eb928eebb73f92#5b30d4912ebb23728f1196b2b9eb928eebb73f92" +dependencies = [ + "darling 0.20.11", + "heck 0.5.0", + "num-bigint", + "proc-macro-crate 3.5.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", + "thiserror 2.0.18", +] + +[[package]] +name = "mysql_async" +version = "0.36.2" +source = "git+https://github.com/t8y2/mysql_async.git?rev=7b565fbe76a168828189f7b98fe3b1989344c54e#7b565fbe76a168828189f7b98fe3b1989344c54e" +dependencies = [ + "bytes", + "crossbeam-queue", + "flate2", + "futures-core", + "futures-sink", + "futures-util", + "keyed_priority_queue", + "lru", + "mysql_common", + "pem", + "percent-encoding", + "rand 0.9.4", + "rustls 0.23.40", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "twox-hash", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "mysql_common" +version = "0.35.5" +source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=5b30d4912ebb23728f1196b2b9eb928eebb73f92#5b30d4912ebb23728f1196b2b9eb928eebb73f92" +dependencies = [ + "base64 0.22.1", + "bitflags 2.11.1", + "btoi", + "byteorder", + "bytes", + "chrono", + "crc32fast", + "curve25519-dalek 4.1.3", + "flate2", + "getrandom 0.3.4", + "mysql-common-derive", + "num-bigint", + "num-traits", + "regex", + "rust_decimal", + "saturating", + "serde", + "serde_json", + "sha1 0.10.6", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "serde", + "smallvec", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "p256" +version = "0.14.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "018bfbb86e05fd70a83e985921241035ee09fcd369c4a2c3680b389a01d2ad28" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c91df688211f5957dbe2ab599dcbcaade8d6d3cdc15c5b350d350d7d07ce423" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto 0.3.0", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6cd9451de522549d36cc78a1b45a699a3d55a872e8ea0c8f0318e502d99e2c" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.1", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "windows 0.62.2", + "windows-strings 0.5.1", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.0", + "spki 0.8.0-rc.4", +] + +[[package]] +name = "pkcs5" +version = "0.8.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6" +dependencies = [ + "aes 0.9.0", + "aes-gcm 0.11.0-rc.3", + "cbc 0.2.1", + "der 0.8.0", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki 0.8.0-rc.4", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der 0.8.0", + "pkcs5", + "rand_core 0.10.1", + "spki 0.8.0-rc.4", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portpicker" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?branch=master#8d50befeee7cdeb8ecdb97c7b790c91010971063" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "pbkdf2 0.12.2", + "rand 0.10.1", + "sha1 0.10.6", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?branch=master#8d50befeee7cdeb8ecdb97c7b790c91010971063" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator 0.2.0", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primefield" +version = "0.14.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93401c13cc7ff24684571cfca9d3cf9ebabfaf3d4b7b9963ade41ec54da196b5" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "rand_core 0.10.1", + "rustcrypto-ff", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c5c8a39bcd764bfedf456e8d55e115fe86dda3e0f555371849f2a41cbc9706" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.40", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.40", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "redis" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44" +dependencies = [ + "bytes", + "cfg-if", + "combine", + "crc16", + "futures-sink", + "futures-util", + "itoa", + "log", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "rand 0.9.4", + "rustls 0.23.40", + "rustls-native-certs 0.8.3", + "ryu", + "sha1_smol", + "socket2 0.6.3", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls 0.23.40", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" +dependencies = [ + "hmac 0.13.0", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb9fd8c1edd9e6a2693623baf0fe77ff05ce022a5d7746900ffc38a15c233de" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki 0.8.0-rc.4", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "russh" +version = "0.60.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "324b92f459d3e42da294e14e8eb150d2215fcfb7c966838bc1127cd68bc05a0d" +dependencies = [ + "aead 0.6.0-rc.10", + "aes 0.8.4", + "aes 0.9.0", + "aes-gcm 0.11.0-rc.3", + "aws-lc-rs", + "bitflags 2.11.1", + "block-padding 0.3.3", + "byteorder", + "bytes", + "cbc 0.1.2", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint", + "ctr 0.10.1", + "ctr 0.9.2", + "curve25519-dalek 5.0.0-pre.6", + "data-encoding", + "delegate", + "der 0.8.0", + "digest 0.10.7", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.1", + "getrandom 0.2.17", + "ghash 0.6.0", + "hex-literal", + "hkdf", + "hmac 0.12.1", + "hmac 0.13.0", + "inout 0.1.4", + "internal-russh-forked-ssh-key", + "internal-russh-num-bigint", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2 0.12.2", + "pbkdf2 0.13.0", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval 0.7.1", + "rand 0.10.1", + "rand_core 0.10.1", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.10.6", + "sha1 0.11.0", + "sha2 0.10.9", + "sha2 0.11.0", + "sha3", + "signature", + "spki 0.8.0-rc.4", + "ssh-encoding", + "subtle", + "thiserror 2.0.18", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.60.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37cb4d0360bdd8935392a306d8b5edb539cc455b30e8bf13dd213a0cf7879b40" +dependencies = [ + "log", + "nix", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "postgres-types", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustcrypto-ff" +version = "0.14.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "rustcrypto-group" +version = "0.14.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed" +dependencies = [ + "rand_core 0.10.1", + "rustcrypto-ff", + "subtle", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs 0.8.3", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der 0.8.0", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "cbc 0.1.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "ctr 0.9.2", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.3", + "rustls 0.23.40", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rust_decimal", + "rustls-native-certs 0.6.3", + "rustls-pemfile 1.0.4", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?branch=master#8d50befeee7cdeb8ecdb97c7b790c91010971063" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2 0.6.3", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid 0.9.6", + "ring", + "rustls 0.23.40", + "tokio", + "tokio-postgres", + "tokio-rustls 0.26.4", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.11.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "aes 0.8.4", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.4", + "hmac 0.12.1", + "indexmap 2.14.0", + "liblzma", + "memchr", + "pbkdf2 0.12.2", + "ppmd-rust", + "sha1 0.10.6", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8b323eb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = ["src-tauri", "crates/dbx-core", "crates/dbx-web"] + +[patch.crates-io] +tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", branch = "master" } +postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", branch = "master" } +postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", branch = "master" } +# Temporary compatibility patch for MySQL accounts using the deprecated +# sha256_password auth plugin. Remove after upstream support lands. +mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "7b565fbe76a168828189f7b98fe3b1989344c54e" } +mysql_common = { git = "https://github.com/t8y2/rust_mysql_common.git", rev = "5b30d4912ebb23728f1196b2b9eb928eebb73f92" } + +[profile.release] +panic = "abort" +strip = true +lto = true +codegen-units = 1 +opt-level = "s" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..620a5e3 --- /dev/null +++ b/Makefile @@ -0,0 +1,90 @@ +.DEFAULT_GOAL := dev + +PNPM ?= pnpm +TAURI_DEV_PORT ?= 1420 + +.PHONY: help install docs-install check-tauri-dev-port dev dev-fast dev-web dev-backend build package docs docs-build check test cargo-check-fast cargo-test-fast + +node_modules/.modules.yaml: package.json pnpm-lock.yaml + $(PNPM) install --frozen-lockfile + +docs/node_modules/.modules.yaml: docs/package.json docs/pnpm-lock.yaml + cd docs && $(PNPM) install --frozen-lockfile --ignore-workspace + +help: + @printf '%s\n' 'DBX development targets:' + @printf '%s\n' '' + @printf '%s\n' 'App:' + @printf ' %-23s %s\n' 'make' 'Start the local desktop development environment' + @printf ' %-23s %s\n' 'make dev' 'Start the local desktop development environment' + @printf ' %-23s %s\n' 'make dev-fast' 'Start Tauri dev without default Rust features' + @printf ' %-23s %s\n' 'make dev-web' 'Start the web frontend development server' + @printf ' %-23s %s\n' 'make dev-backend' 'Start the web backend development server' + @printf ' %-23s %s\n' 'make build' 'Run type checks and build the desktop frontend' + @printf ' %-23s %s\n' 'make package' 'Build the desktop app package' + @printf '%s\n' '' + @printf '%s\n' 'Docs:' + @printf ' %-23s %s\n' 'make docs' 'Start the documentation site development server' + @printf ' %-23s %s\n' 'make docs-build' 'Build the documentation site' + @printf ' %-23s %s\n' 'make docs-install' 'Install documentation site dependencies' + @printf '%s\n' '' + @printf '%s\n' 'Checks:' + @printf ' %-23s %s\n' 'make check' 'Run project checks' + @printf ' %-23s %s\n' 'make test' 'Run project tests' + @printf ' %-23s %s\n' 'make cargo-check-fast' 'Run Rust check without default features' + @printf ' %-23s %s\n' 'make cargo-test-fast' 'Run Rust tests without default features' + @printf '%s\n' '' + @printf '%s\n' 'Setup:' + @printf ' %-23s %s\n' 'make install' 'Install root project dependencies' + +install: + $(PNPM) install --frozen-lockfile + +docs-install: + cd docs && $(PNPM) install --frozen-lockfile --ignore-workspace + +check-tauri-dev-port: + @if lsof -nP -iTCP:$(TAURI_DEV_PORT) -sTCP:LISTEN >/dev/null 2>&1; then \ + echo "Port $(TAURI_DEV_PORT) is already in use. DBX Tauri dev requires http://localhost:$(TAURI_DEV_PORT)."; \ + echo ""; \ + lsof -nP -iTCP:$(TAURI_DEV_PORT) -sTCP:LISTEN; \ + echo ""; \ + echo "Stop the process above, then run make dev again. Example: kill "; \ + exit 1; \ + fi + +dev: node_modules/.modules.yaml check-tauri-dev-port + $(PNPM) dev:tauri + +dev-fast: node_modules/.modules.yaml check-tauri-dev-port + $(PNPM) tauri dev -- --no-default-features + +dev-web: node_modules/.modules.yaml + $(PNPM) dev:web + +dev-backend: node_modules/.modules.yaml + $(PNPM) dev:backend + +build: node_modules/.modules.yaml + $(PNPM) build:checked + +package: node_modules/.modules.yaml + $(PNPM) tauri build + +docs: docs/node_modules/.modules.yaml + cd docs && ./node_modules/.bin/next dev --hostname 127.0.0.1 + +docs-build: docs/node_modules/.modules.yaml + cd docs && ./node_modules/.bin/next build && node scripts/generate-sitemap.mjs + +check: node_modules/.modules.yaml + $(PNPM) check + +test: node_modules/.modules.yaml + $(PNPM) test + +cargo-check-fast: + cargo check --no-default-features + +cargo-test-fast: + cargo test --no-default-features diff --git a/README-NIX.md b/README-NIX.md new file mode 100644 index 0000000..99d1ed5 --- /dev/null +++ b/README-NIX.md @@ -0,0 +1,172 @@ +## Add to NixOS system configuration + +Add DBX as a flake input and include the package in `environment.systemPackages`. + +### `flake.nix` + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + dbx = { + url = "github:t8y2/dbx"; + inputs.nixpkgs.follows = "nixpkgs"; # optional + }; + }; + + outputs = { self, nixpkgs, dbx, ... }: { + nixosConfigurations.my-machine = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ./configuration.nix + { + environment.systemPackages = [ + dbx.packages.x86_64-linux.dbx-desktop + ]; + } + ]; + }; + }; +} +``` + +### Apply + +```bash +sudo nixos-rebuild switch --flake .#my-machine +``` + +--- + +## Add via Home Manager + +This method installs DBX for a specific user through [Home Manager](https://github.com/nix-community/home-manager). + +> [!NOTE] +> The `inputs.nixpkgs.follows = "nixpkgs"` line is **optional**. +> It prevents Nix from downloading a second copy of nixpkgs, but it also makes +> DBX ineligible for the upstream binary cache (it will be built locally). +> Remove that line if you prefer to download a pre-built binary. + +### `flake.nix` (standalone Home Manager) + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + dbx = { + url = "github:t8y2/dbx"; + inputs.nixpkgs.follows = "nixpkgs"; # optional + }; + }; + + outputs = { self, nixpkgs, home-manager, dbx, ... }: { + homeConfigurations."youruser" = home-manager.lib.homeManagerConfiguration { + pkgs = nixpkgs.legacyPackages.x86_64-linux; + system = "x86_64-linux"; + modules = [ + { + home.packages = [ + dbx.packages.x86_64-linux.dbx-desktop + ]; + } + ]; + }; + }; +} +``` + +### `flake.nix` (NixOS + Home Manager as a NixOS module) + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + dbx = { + url = "github:t8y2/dbx"; + inputs.nixpkgs.follows = "nixpkgs"; # optional + }; + }; + + outputs = { self, nixpkgs, home-manager, dbx, ... }: { + nixosConfigurations.my-machine = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ./configuration.nix + home-manager.nixosModules.home-manager + { + home-manager.users.youruser = { + home.packages = [ + dbx.packages.x86_64-linux.dbx-desktop + ]; + }; + } + ]; + }; + }; +} +``` + +> Replace `youruser` with your actual Linux username and `my-machine` with your hostname. + +### Apply (standalone) + +```bash +home-manager switch --flake .#youruser +``` + +### Apply (NixOS module) + +```bash +sudo nixos-rebuild switch --flake .#my-machine +``` + +--- + +## Development Shell + +If you are contributing to DBX or building it from a local clone, the flake provides a fully configured development shell with Rust, Node.js, pnpm, and all GTK/WebKit system libraries: + +```bash +git clone https://github.com/t8y2/dbx +cd dbx +nix develop +``` + +Inside the shell: + +| Task | Command | +|---|---| +| Desktop app (Tauri) | `pnpm install && pnpm dev:tauri` | +| Web frontend only | `pnpm dev:web` | +| Web backend only | `pnpm dev:backend` | +| Release build | `pnpm tauri build` | + +--- + +## Building from Source + +Build the `dbx-desktop` package directly from the flake: + +```bash +nix build github:t8y2/dbx#dbx-desktop +# or, from a local clone: +nix build .#dbx-desktop +``` + +The resulting binary is available at `./result/bin/dbx`. + +--- diff --git a/README.md b/README.md new file mode 100644 index 0000000..36c99fc --- /dev/null +++ b/README.md @@ -0,0 +1,433 @@ +
+

60+ databases in 20 MB. Desktop & Docker self-hosting, with built-in AI assistant.

+ +

+ DBX screenshot +

+ +

+ + + Join QQ Group + Join WeChat Group + Join Discord +

+

+ t8y2%2Fdbx | Trendshift + Featured|HelloGitHub + DBX - Lightweight open-source database manager built with Rust | Product Hunt +

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + AtomGit Stars + CNB +

+

+ English | 前往中文版本 +

+ +

+ + +

+

+ + +

+
+ +## Why DBX? + + + + + + + + + + +
+

🪶 20 MB, zero runtime bloat

+

No Java JRE. No Python venv. No bundled Chromium. DBX ships as a single small binary — download, install, connect. DBeaver needs Java; TablePlus is macOS-only. DBX runs everywhere with nothing extra.

+
+

🤖 AI that lives in your editor

+

Highlight a table, describe what you want, get SQL back — no copy-paste between tools. Works with Claude, OpenAI, or local models via Ollama. Built-in safety checks review AI-generated SQL before it runs.

+
+

🔌 MCP: your databases, AI-ready

+

DBX speaks the Model Context Protocol. Claude Code, Cursor, Windsurf, and other AI coding agents can query your databases through connections you already set up. One config, everywhere.

+
+

🌐 Desktop + Docker + Web

+

Native app on macOS, Windows, and Linux. Self-host via Docker for team access. Web version for browser-only environments. Same feature set. Same connections.

+
+ +## Features + +### 60+ Databases, One Tool + +MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium. + +### Query Editor + +CodeMirror 6 with SQL syntax highlighting, metadata-aware autocomplete, `Cmd+Enter` execution, selected SQL execution, SQL formatting, diagnostics, and 9 editor themes. Persistent query history, saved SQL snippets, tab restore, and SQL file execution keep repeat work close at hand. + +### AI SQL Assistant + +Describe what you want in plain language — get SQL back. DBX can explain queries, optimize SQL, fix errors, and run AI-generated SQL through built-in safety checks. Works with Claude, OpenAI, local models, or any OpenAI-compatible endpoint. + +### Data Grid + +Virtual-scrolled table that handles large result sets. Inline editing, SQL preview before save, WHERE / ORDER BY controls, DataGrip-style filters, LIKE / NOT LIKE context filters, sorting, full-text search, pagination, column resize, auto-fit, row numbers, zebra stripes, and full cell details. Export or copy as CSV, JSON, Markdown, XLSX, or INSERT statements. + +### Schema Tools + +- **Schema browser** — databases, schemas, tables, columns, indexes, foreign keys, triggers, with sidebar search & pin +- **Object browser** — grouped procedures, functions, views, and source editing where supported +- **Table structure editor** — reviewable column and index changes for supported engines +- **ER diagram** — visualize table relationships +- **Schema diff** — compare structures across connections +- **Explain plan** — visual query execution plan +- **Field lineage** — column-level lineage analysis +- **Database search** — find objects across large schemas + +### Data Operations + +- **Table import** — CSV, Excel +- **Data transfer** — migrate between databases +- **Database export** — full database dump +- **Data compare** — compare table data and review synchronization output +- **SQL file execution** — run `.sql` files directly +- **File preview** — drag & drop Parquet, CSV, JSON to preview instantly (powered by DuckDB) +- **Connection import** — bring connection profiles from DBeaver or Navicat + +### Specialized Browsers + +- **Redis** — key pattern search, batch key operations, command runner, TTL editing, and all data types (String, Hash, List, Set, ZSet, Stream) +- **MongoDB** — document CRUD with pagination, Atlas & replica set URL connection + +### Safety & Connectivity + +SSH tunnel (key & password) · database and AI proxy settings · auto-reconnect on connection loss · confirmation dialogs for destructive operations · encrypted config export/import · color-coded connections · driver store and optional JDBC plugin + +### Polished UI + +Dark mode with native title bar sync · 9 editor themes · English, 简体中文 & Español · layout preferences · built-in auto-update + +## AI Agent Integration (MCP) + +DBX provides an [MCP server](packages/mcp-server/) that lets AI coding agents query your databases using connections already configured in DBX. + +```bash +npx @dbx-app/mcp-server +``` + +Add to your `.mcp.json`: + +```json +{ + "mcpServers": { + "dbx": { "command": "npx", "args": ["-y", "@dbx-app/mcp-server"] } + } +} +``` + +Windows portable builds need `DBX_DATA_DIR` in the MCP config, pointing to the `data` directory next to `DBX.exe` (the folder that contains `dbx.db`). + +For DBX Web or Docker deployments, point the MCP server at the Web backend API. If the Web login page requires a password, set `DBX_WEB_PASSWORD` to the same password used there: + +```json +{ + "mcpServers": { + "dbx": { + "command": "npx", + "args": ["-y", "@dbx-app/mcp-server"], + "env": { + "DBX_WEB_URL": "http://localhost:4224", + "DBX_WEB_PASSWORD": "your-web-login-password" + } + } + } +} +``` + +Works with Claude Code, Cursor, Windsurf, and any MCP-compatible agent. Supports listing connections, browsing tables, executing SQL, and opening tables directly in DBX's UI. + +DBX also provides a dedicated CLI package for terminal, script, and Codex workflows: + +```bash +npm install -g @dbx-app/cli +# or via Homebrew +brew tap t8y2/dbx && brew install dbx-cli +dbx connections list --json +dbx query local "select 1" --json +``` + +See the [MCP server README](packages/mcp-server/README.md) and [CLI README](packages/cli/README.md) for details. + +## Install + +Download the latest release from the [Releases](https://github.com/t8y2/dbx/releases/latest) page. + +**Homebrew (macOS):** + +```bash +brew install --cask dbx +``` + +**Scoop (Windows):** + +```bash +scoop bucket add dbx https://github.com/t8y2/scoop-bucket +scoop install dbx +``` + +**WinGet (Windows):** + +``` +winget install t8y2.dbx +``` + +**Flatpak (Linux):** + +```bash +flatpak remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo +flatpak install flatpark com.dbxio.dbx +``` + +Updates then arrive through the regular `flatpak update`. See the [DBX page on FlatPark](https://flatpark.org/apps/com.dbxio.dbx/) for details. + +## Self-Hosted (Docker) + +DBX provides a web version that can be deployed via Docker. The examples use +the `latest` tag to pull the current release. + +```bash +docker run -d --pull=always --name dbx -p 4224:4224 -v dbx-data:/app/data t8y2/dbx:latest +``` + +This uses the cross-platform `dbx-data` named volume. Users in China can use +the CNB image, `docker.cnb.cool/dbxio.com/dbx:latest`, for faster pulls. + +For Docker Compose, `deploy/docker-compose.yml` remains the source-build +configuration. To deploy a published image, use +`deploy/docker-compose.release.yml`: + +```bash +docker compose -f deploy/docker-compose.release.yml up -d +``` + +```yaml +services: + dbx: + image: t8y2/dbx:latest + # For faster pulls in China, use the CNB image instead: + # image: docker.cnb.cool/dbxio.com/dbx:latest + pull_policy: always + ports: + - "4224:4224" + volumes: + - dbx-data:/app/data + restart: unless-stopped + +volumes: + dbx-data: +``` + +Open `http://localhost:4224` in your browser. Multi-arch images (amd64 / arm64) are available. + +To publish DBX under a reverse-proxy context path such as `/dbx`, set the +runtime base path and proxy the same prefix to the container: + +```yaml +environment: + - DBX_PUBLIC_BASE_PATH=/dbx +``` + +When building the frontend yourself with an absolute asset base, set +`VITE_DBX_BASE_PATH=/dbx/` before `pnpm build`. + +## Getting Started + +### Prerequisites + +- [Node.js](https://nodejs.org/) >= 18 +- [pnpm](https://pnpm.io/) +- [Rust](https://www.rust-lang.org/tools/install) >= 1.77 + +#### System Dependencies + +**macOS:** + +No additional dependencies required. + +**Linux (Ubuntu/Debian):** + +```bash +sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev +``` + +**NIXOS/NIX :** + +See README-NIX.md + +**Windows:** + +No additional dependencies required. + +### Development + +```bash +make +``` + +`make` installs root dependencies when needed and starts the local Tauri desktop development environment. + +> [!TIP] +> DuckDB compilation takes a while. If you're not working on DuckDB features, +> skip it to speed up local builds: +> +> ```bash +> # Fast checks (skip DuckDB) +> make cargo-check-fast +> make cargo-test-fast +> +> # Tauri dev without DuckDB +> make dev-fast +> ``` +> +> The `--no-default-features` flag only affects local development. +> Release builds (`pnpm tauri build`) always include DuckDB. + +Web version: + +```bash +make dev-web # frontend +make dev-backend # backend +``` + +Documentation site: + +```bash +make docs +``` + +The official DBX documentation site lives in `docs/`. If you want to improve the website content or documentation pages, edit the files under `docs/` and run `make docs` to preview the site locally. + +JDBC agent driver development projects live in `agents/`: + +```bash +cd agents +./gradlew test +``` + +Build artifacts from `agents/drivers//build/libs/` are picked up by local driver install flows when available. + +### Build + +```bash +make package +``` + +The installer will be in `src-tauri/target/release/bundle/`. + +## Tech Stack + +| Layer | Technology | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Framework | [Tauri 2](https://tauri.app/) | +| Frontend | [Vue 3](https://vuejs.org/) + TypeScript | +| UI | [shadcn-vue](https://www.shadcn-vue.com/) + Tailwind CSS | +| Editor | [CodeMirror 6](https://codemirror.net/) | +| Backend | Rust + [sqlx](https://github.com/launchbadge/sqlx) / [tiberius](https://github.com/prisma/tiberius) / [redis-rs](https://github.com/redis-rs/redis-rs) / [mongodb](https://github.com/mongodb/mongo-rust-driver) | + +## Documentation + +- [Official docs](https://dbxio.com/en/docs/what-is-dbx) — feature guides and tutorials +- [Contributing](CONTRIBUTING.md) — how to pick up issues and open PRs +- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments +- [Examples](examples/) — CLI, MCP, Docker, and API samples + +## Community + +Discord +QQ Group +WeChat Group +[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-Community-blue)](https://linux.do) + +## FAQ + +
+Is DBX free? +Yes. DBX is open source under Apache-2.0. All features are free. +
+ +
+Does DBX phone home? +No. DBX does not collect telemetry. The auto-update feature checks GitHub Releases for new versions — you can disable it in settings. +
+ +
+Can I use DBX without an internet connection? +Yes. The desktop app works fully offline. For air-gapped driver installs, download offline driver packages from the [Offline Drivers page](https://dbxio.com/en/drivers) on an internet-connected machine, transfer them to the offline machine, then import them in DBX from Settings > Driver Manager. AI features need network access to the model endpoint (or a local model via Ollama). +
+ +
+How is DBX different from DBeaver / TablePlus / Beekeeper Studio? +DBX is 20 MB with no runtime dependencies (no Java, no Python). It includes AI and MCP natively — not as plugins. It supports 60+ databases across desktop, Docker, and web from a single codebase. +
+ +
+What databases are supported? +MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported. +
+ +
+How do I report a bug or request a feature? +Open an issue on GitHub Issues. +
+ +## Contributors + + + + + +## Star History + + + + + + Star History Chart + + + +## License + +[Apache-2.0](LICENSE) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..db2551e --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`t8y2/dbx` +- 原始仓库:https://github.com/t8y2/dbx +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..8dc73a6 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,427 @@ +
+

20 MB 驾驭 60+ 种数据库。桌面端 & Docker 自托管,内置 AI 助手。

+ +

+ DBX 截图 +

+ +

+ + + 加入 QQ 群 + 加入微信交流群 + 加入 Discord +

+

+ t8y2%2Fdbx | Trendshift + Featured|HelloGitHub + DBX - Lightweight open-source database manager built with Rust | Product Hunt +

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + AtomGit Stars + CNB +

+

+ English | 简体中文 +

+ +

+ + +

+

+ + +

+
+ +## 为什么选择 DBX? + + + + + + + + + + +
+

🪶 20 MB,极致轻量

+

无需 Java 运行环境,无需 Python 虚拟环境,不内嵌 Chromium。DBX 是单个小巧的二进制文件——下载、安装、连接。DBeaver 依赖 Java;TablePlus 仅限 macOS。DBX 全平台可用,无需额外运行时。

+
+

🤖 AI 原生集成在编辑器里

+

选中一张表,描述你想要什么,直接得到 SQL——无需在工具之间复制粘贴。支持 Claude、OpenAI,或通过 Ollama 使用本地模型。内置安全检查会在执行前审查 AI 生成的 SQL。

+
+

🔌 MCP 协议:你的数据库,AI 就绪

+

DBX 原生支持 Model Context Protocol。Claude Code、Cursor、Windsurf 等 AI 编程助手可以直接通过你已配置的数据库连接查询数据。一次配置,处处可用。

+
+

🌐 桌面端 + Docker + Web

+

macOS、Windows、Linux 原生应用。通过 Docker 自托管供团队访问。Web 版本适配纯浏览器环境。同样的功能,同样的连接配置。

+
+ +## 功能特性 + +### 60+ 种数据库,一个工具搞定 + +MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。 + +### 查询编辑器 + +CodeMirror 6 语法高亮、元数据感知自动补全、`Cmd+Enter` 执行、选中 SQL 执行、SQL 格式化、诊断提示,9 种编辑器主题。查询历史、常用 SQL 片段、标签页恢复和 SQL 文件执行让重复工作更顺手。 + +### AI SQL 助手 + +用自然语言描述你的需求,直接生成 SQL。还能解释查询、优化 SQL、修复错误,并通过内置安全检查执行 AI 生成的 SQL。支持 Claude、OpenAI、本地模型或任何 OpenAI 兼容端点。 + +### 数据表格 + +虚拟滚动,轻松应对大型结果集。行内编辑、保存前 SQL 预览、WHERE / ORDER BY 控件、DataGrip 风格过滤器、LIKE / NOT LIKE 右键过滤、排序、全文搜索、分页、列宽调整、自动列宽、行号、斑马纹和完整单元格详情。支持导出或复制为 CSV、JSON、Markdown、XLSX、INSERT 语句。 + +### Schema 工具 + +- **结构浏览** — 数据库、Schema、表、字段、索引、外键、触发器,支持侧边栏搜索和置顶 +- **对象浏览** — 按类型分组查看过程、函数、视图,并在支持的数据库中编辑源码 +- **表结构编辑器** — 对支持的数据库执行可审查的字段和索引变更 +- **ER 关系图** — 可视化表间关联 +- **Schema 对比** — 跨连接对比表结构差异 +- **执行计划** — 可视化查询执行计划 +- **字段血缘** — 字段级血缘分析 +- **数据库搜索** — 在大型 Schema 中快速查找对象 + +### 数据操作 + +- **数据导入** — CSV、Excel +- **数据迁移** — 在数据库之间迁移数据 +- **数据库导出** — 完整数据库导出 +- **数据对比** — 对比表数据并审查同步结果 +- **SQL 文件执行** — 直接执行 `.sql` 文件 +- **文件预览** — 拖入 Parquet、CSV、JSON 即时预览(基于 DuckDB) +- **连接导入** — 从 DBeaver 或 Navicat 导入连接配置 + +### 专项浏览器 + +- **Redis** — 模式匹配搜索、批量键操作、命令执行器、TTL 编辑,全数据类型支持(String、Hash、List、Set、ZSet、Stream) +- **MongoDB** — 文档增删改查、分页浏览,支持 Atlas 和副本集 URL 直连 + +### 安全与连接 + +SSH 隧道(密钥和密码认证)· 数据库和 AI 代理设置 · 断线自动重连 · 危险操作确认对话框 · 加密导出/导入连接配置 · 连接颜色标记 · 驱动商店与可选 JDBC 插件 + +### 精致 UI + +深色模式原生标题栏同步 · 9 种编辑器主题 · English、简体中文、Español · 布局偏好设置 · 内置自动更新 + +## AI 编程助手集成 (MCP) + +DBX 提供 [MCP Server](packages/mcp-server/),让 AI 编程助手直接使用 DBX 中已配置的数据库连接查询数据。 + +```bash +npx @dbx-app/mcp-server +``` + +在 `.mcp.json` 中添加: + +```json +{ + "mcpServers": { + "dbx": { "command": "npx", "args": ["-y", "@dbx-app/mcp-server"] } + } +} +``` + +Windows 便携版需要在 MCP 配置中设置 `DBX_DATA_DIR`,指向 `DBX.exe` 同级的 `data` 目录(即包含 `dbx.db` 的文件夹)。 + +如果连接的是 DBX Web 或 Docker 部署,请让 MCP Server 指向 Web 后端 API。如果 Web 登录页需要密码,`DBX_WEB_PASSWORD` 填写同一个 Web 登录密码: + +```json +{ + "mcpServers": { + "dbx": { + "command": "npx", + "args": ["-y", "@dbx-app/mcp-server"], + "env": { + "DBX_WEB_URL": "http://localhost:4224", + "DBX_WEB_PASSWORD": "你的 Web 登录密码" + } + } + } +} +``` + +支持 Claude Code、Cursor、Windsurf 等 MCP 兼容的 AI 助手。可列出连接、浏览表、执行 SQL,还能直接在 DBX 界面中打开表。 + +DBX 也提供独立 CLI 包,适合终端、脚本和 Codex 工作流: + +```bash +npm install -g @dbx-app/cli +# 或通过 Homebrew +brew tap t8y2/dbx && brew install dbx-cli +dbx connections list --json +dbx query local "select 1" --json +``` + +详见 [MCP Server 说明](packages/mcp-server/README.md) 和 [CLI 说明](packages/cli/README.md)。 + +## 安装 + +从 [Releases](https://github.com/t8y2/dbx/releases/latest) 页面下载最新版本。 + +**Homebrew (macOS):** + +```bash +brew install --cask dbx +``` + +**Scoop (Windows):** + +```bash +scoop bucket add dbx https://github.com/t8y2/scoop-bucket +scoop install dbx +``` + +**WinGet (Windows):** + +``` +winget install t8y2.dbx +``` + +**Flatpak (Linux):** + +```bash +flatpak remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo +flatpak install flatpark com.dbxio.dbx +``` + +之后通过常规的 `flatpak update` 即可获取更新。详见 [FlatPark 上的 DBX 页面](https://flatpark.org/apps/com.dbxio.dbx/)。 + +## 自托管 (Docker) + +DBX 提供 Web 版本,可通过 Docker 部署。示例使用 `latest` 标签以拉取当前发布版本。 + +```bash +docker run -d --pull=always --name dbx -p 4224:4224 -v dbx-data:/app/data t8y2/dbx:latest +``` + +这里使用跨平台的 `dbx-data` 命名卷。中国大陆用户可选用 CNB 镜像 +`docker.cnb.cool/dbxio.com/dbx:latest`,以获得更快的拉取速度。 + +使用 Docker Compose 时,`deploy/docker-compose.yml` 保留为源码构建配置。 +如需部署已发布的镜像,请使用 `deploy/docker-compose.release.yml`: + +```bash +docker compose -f deploy/docker-compose.release.yml up -d +``` + +```yaml +services: + dbx: + image: t8y2/dbx:latest + # 中国大陆用户可改用 CNB 镜像,以加快拉取速度: + # image: docker.cnb.cool/dbxio.com/dbx:latest + pull_policy: always + ports: + - "4224:4224" + volumes: + - dbx-data:/app/data + restart: unless-stopped + +volumes: + dbx-data: +``` + +如需通过 nginx 等反向代理发布到 `/dbx` 这类子路径下,设置运行时上下文路径,并将同一前缀代理到容器: + +```yaml +environment: + - DBX_PUBLIC_BASE_PATH=/dbx +``` + +如果自行从源码构建前端并希望使用绝对资源路径,可在 `pnpm build` 前设置 `VITE_DBX_BASE_PATH=/dbx/`。 + +浏览器访问 `http://localhost:4224`。支持 amd64 / arm64 双架构镜像。 + +## 快速开始 + +### 环境要求 + +- [Node.js](https://nodejs.org/) >= 18 +- [pnpm](https://pnpm.io/) +- [Rust](https://www.rust-lang.org/tools/install) >= 1.77 + +#### 系统依赖 + +**macOS:** + +无需额外安装。 + +**Linux (Ubuntu/Debian):** + +```bash +sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev +``` + +**NIXOS/NIX :** + +查看 README-NIX.md + +**Windows:** + +无需额外安装。 + +### 开发 + +```bash +make +``` + +`make` 会在需要时安装根目录依赖,并启动本地 Tauri 桌面端开发环境。 + +> [!TIP] +> DuckDB 从源码编译较慢。如果不涉及 DuckDB 功能,可以跳过以加速本地构建: +> +> ```bash +> # 快速检查(跳过 DuckDB) +> make cargo-check-fast +> make cargo-test-fast +> +> # Tauri 开发模式跳过 DuckDB +> make dev-fast +> ``` +> +> `--no-default-features` 仅影响本地开发,发布构建(`pnpm tauri build`)始终包含 DuckDB。 + +Web 版本: + +```bash +make dev-web # 前端 +make dev-backend # 后端 +``` + +文档站: + +```bash +make docs +``` + +DBX 官网文档位于 `docs/` 目录。如果你想贡献官网内容或文档页面,请修改 `docs/` 下的文件,并运行 `make docs` 在本地预览文档站。 + +JDBC Agent 驱动开发工程位于 `agents/` 目录: + +```bash +cd agents +./gradlew test +``` + +本地驱动安装流程会优先查找 `agents/drivers//build/libs/` 下的构建产物。 + +### 构建 + +```bash +make package +``` + +安装包输出在 `src-tauri/target/release/bundle/` 目录。 + +## 技术栈 + +| 层级 | 技术 | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 框架 | [Tauri 2](https://tauri.app/) | +| 前端 | [Vue 3](https://vuejs.org/) + TypeScript | +| UI | [shadcn-vue](https://www.shadcn-vue.com/) + Tailwind CSS | +| 编辑器 | [CodeMirror 6](https://codemirror.net/) | +| 后端 | Rust + [sqlx](https://github.com/launchbadge/sqlx) / [tiberius](https://github.com/prisma/tiberius) / [redis-rs](https://github.com/redis-rs/redis-rs) / [mongodb](https://github.com/mongodb/mongo-rust-driver) | + +## 文档 + +- [官方文档](https://dbxio.com/cn/docs/what-is-dbx) — 功能说明与使用教程 +- [贡献指南](CONTRIBUTING.zh-CN.md) — 如何认领 Issue 并提交 PR +- [Web API 参考](docs/content/docs/web-api.cn.mdx) — Docker/Web 部署的 HTTP API +- [示例代码](examples/) — CLI、MCP、Docker 与 API 示例 + +## 社区 + +Discord +QQ 群 +微信交流群 +[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-社区-blue)](https://linux.do) + +## 常见问题 + +
+DBX 是免费的吗? +是的。DBX 基于 Apache-2.0 协议开源,所有功能均免费使用。 +
+ +
+DBX 会收集用户数据吗? +不会。DBX 不收集任何遥测数据。自动更新功能仅检查 GitHub Releases 获取新版本——你可以在设置中禁用它。 +
+ +
+可以离线使用吗? +可以。桌面端完全支持离线使用。内网环境安装驱动时,可在有网机器打开[离线驱动下载页](https://dbxio.com/cn/drivers)下载离线驱动包,传输到内网机器后,在 DBX 的「设置 > 驱动管理」中导入。AI 功能需要网络访问模型端点(或通过 Ollama 使用本地模型)。 +
+ +
+DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别? +DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 60+ 数据库、桌面端、Docker 和 Web。 +
+ +
+支持哪些数据库? +MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等。JDBC 方向配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。 +
+ +
+如何报告 Bug 或请求新功能? +在 GitHub Issues 提交 Issue。 +
+ +## 贡献者 + + + + + +## Star History + + + + + + Star History Chart + + + +## 开源协议 + +[Apache-2.0](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..31abe6d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not report security vulnerabilities in public GitHub issues. + +Use GitHub's private vulnerability reporting for this repository when available. If that is not available to you, open a minimal GitHub issue asking for a private security contact without including exploit details, credentials, tokens, connection strings, database dumps, or screenshots containing secrets. + +## What to Include + +Helpful reports include: + +- Affected DBX version or commit. +- Operating system and installation method. +- The impacted component, such as desktop app, Docker service, CLI, MCP server, or JDBC plugin. +- Steps to reproduce in a safe test environment. +- Impact assessment and any known workaround. + +## Scope + +Security-sensitive areas include: + +- Connection storage, config import/export, and encryption. +- Database credential handling. +- SSH tunnel and proxy handling. +- AI provider keys and OpenAI-compatible endpoint configuration. +- MCP and CLI access to local DBX connections. +- Docker web service authentication and data directory handling. + +Please avoid testing against systems you do not own or have explicit permission to assess. diff --git a/agents/.gitignore b/agents/.gitignore new file mode 100644 index 0000000..e1afb2a --- /dev/null +++ b/agents/.gitignore @@ -0,0 +1,8 @@ +**/libs/*.jar +!drivers/oscar/libs/oscarJDBC8.jar +gradle.properties +build/ +*/bin/ +.gradle/ +.DS_Store +openspec diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000..51aa7e2 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,114 @@ +# DBX Agents + +English | [简体中文](README.zh-CN.md) + +Agent drivers for [DBX](https://github.com/t8y2/dbx) — database support via JDBC and native database drivers. + +Each agent runs as a standalone process and communicates with DBX via stdin/stdout JSON-RPC 2.0. + +## Supported Databases + +| Agent | Database | JDBC Driver | +|-------|----------|-------------| +| access | Microsoft Access | UCanAccess | +| dameng | 达梦 DM8 | DM JDBC | +| kingbase | 人大金仓 KingbaseES | KingbaseES JDBC | +| vastbase | Vastbase | Vastbase JDBC | +| goldendb | GoldenDB | MySQL Connector/J | +| databend | Databend | Databend JDBC | +| databricks | Databricks SQL | Databricks JDBC | +| saphana | SAP HANA | SAP HANA JDBC | +| teradata | Teradata | Teradata JDBC | +| vertica | Vertica | Vertica JDBC | +| firebird | Firebird | Jaybird JDBC | +| exasol | Exasol | Exasol JDBC | +| oceanbase-oracle | OceanBase Oracle Mode | OceanBase JDBC | +| gbase8a | GBase 8a | External GBase 8a JDBC | +| gbase8s | GBase 8s | External GBase 8s JDBC | +| oracle | Oracle 10g+ | go-ora native agent | +| h2 | H2 | H2 JDBC | +| snowflake | Snowflake | Snowflake JDBC | +| trino | Trino (Presto) | Trino JDBC | +| hive | Apache Hive | Hive JDBC | +| db2 | IBM DB2 | DB2 JDBC | +| informix | IBM Informix | Informix JDBC | +| neo4j | Neo4j | Neo4j JDBC | +| cassandra | Apache Cassandra | Cassandra JDBC | +| bigquery | Google BigQuery | BigQuery JDBC | +| kylin | Apache Kylin | Kylin JDBC | +| sundb | SunDB | SunDB JDBC | +| tdengine | TDengine | taos-jdbcdriver (WebSocket, REST fallback) | +| yashandb | 崖山 YashanDB | YashanDB JDBC | +| xugu | 虚谷 XuguDB | XuguDB Go native agent | +| iotdb | Apache IoTDB | IoTDB JDBC | +| etcd | etcd | jetcd | +| zookeeper | Apache ZooKeeper | Apache Curator | + + +## Multi-JRE Support + +Most Java agents target JRE 21. Native agents, such as `oracle` and `xugu`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents. + +## Choosing a Driver Language + +For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely. + +- **Native (Go/Rust)** — preferred when a usable native driver exists. See `drivers/oracle-go` (go-ora) and `drivers/xugu` as reference implementations. No JRE download or management is needed. +- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category. + +Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both a native and a Java path exist for the same database, default DBX to the native one and keep the Java variant only as a compatibility fallback — see how `oracle` (go-ora native) coexists with `oracle-legacy` / `oracle-10g`. + +## Build + +Requires JDK 21 (Gradle toolchain auto-downloads if needed). + +```bash +./gradlew shadowJar +(cd drivers/oracle-go && go build -o agent .) +(cd drivers/xugu && go build -o agent .) +``` + +Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/oracle-go` and `drivers/xugu`. + +### Local DBX Runtime Test + +When changing a Java agent under `agents/drivers//` or shared Java agent protocol code, rebuild the target agent and replace the runtime JAR used by the local DBX app: + +```bash +./gradlew ::shadowJar +cp ~/.dbx/agents/drivers//agent.jar ~/.dbx/agents/drivers//agent.jar.bak +cp agents/drivers//build/libs/*-all.jar ~/.dbx/agents/drivers//agent.jar +``` + +Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR. + +Native agents such as `oracle` and `xugu` use the `agent` executable in the driver directory instead of `agent.jar`. + +## Versioning + +Agent module versions are tracked in [`versions.json`](versions.json). + +- **Changing an existing driver** — do not edit `versions.json` manually. The release CI diffs each `drivers//` directory against the previous tag and auto-bumps the patch version for every changed module (see [`bump-agent-versions.mjs`](../.github/scripts/bump-agent-versions.mjs)). A change to the shared `agents/common` runtime bumps every module that packages it. +- **Adding a new driver** — add an entry to `versions.json`, e.g. `"rabbitmq": "0.1.0"`. The CI only bumps keys already present in the file, so a new module is invisible to versioning until it is registered here. In the same change, also add the module to `settings.gradle` and the support table above — `versions.json` keys must match the agent modules declared in `settings.gradle`, excluding the infrastructure modules `common` and `test-support`. + +## Development + +- Agent authoring guide: [docs/agent-authoring.md](docs/agent-authoring.md) +- JDBC agent template: [docs/examples/jdbc-agent-template](docs/examples/jdbc-agent-template) +- Release checklist: [docs/release-checklist.md](docs/release-checklist.md) + +## Architecture + +``` +DBX Main Process (Rust/Tauri) + │ stdin/stdout (JSON-RPC 2.0) + ▼ +agent / java -jar dbx-agent-{type}.jar + │ + ▼ +Native driver / JDBC → Database +``` + +## License + +[AGPL-3.0](https://github.com/t8y2/dbx/blob/main/LICENSE) diff --git a/agents/README.zh-CN.md b/agents/README.zh-CN.md new file mode 100644 index 0000000..ef12499 --- /dev/null +++ b/agents/README.zh-CN.md @@ -0,0 +1,114 @@ +# DBX Agents + +[English](README.md) | 简体中文 + +DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数据库。 + +每个 agent 作为独立进程运行,通过 stdin/stdout 与 DBX 进行 JSON-RPC 2.0 通信。 + +## 支持的数据库 + +| Agent | 数据库 | JDBC 驱动 | +|-------|----------|-------------| +| access | Microsoft Access | UCanAccess | +| dameng | 达梦 DM8 | DM JDBC | +| kingbase | 人大金仓 KingbaseES | KingbaseES JDBC | +| vastbase | Vastbase | Vastbase JDBC | +| goldendb | GoldenDB | MySQL Connector/J | +| databend | Databend | Databend JDBC | +| databricks | Databricks SQL | Databricks JDBC | +| saphana | SAP HANA | SAP HANA JDBC | +| teradata | Teradata | Teradata JDBC | +| vertica | Vertica | Vertica JDBC | +| firebird | Firebird | Jaybird JDBC | +| exasol | Exasol | Exasol JDBC | +| oceanbase-oracle | OceanBase Oracle 模式 | OceanBase JDBC | +| gbase8a | GBase 8a | 外部 GBase 8a JDBC | +| gbase8s | GBase 8s | 外部 GBase 8s JDBC | +| oracle | Oracle 10g+ | go-ora 原生 agent | +| h2 | H2 | H2 JDBC | +| snowflake | Snowflake | Snowflake JDBC | +| trino | Trino (Presto) | Trino JDBC | +| hive | Apache Hive | Hive JDBC | +| db2 | IBM DB2 | DB2 JDBC | +| informix | IBM Informix | Informix JDBC | +| neo4j | Neo4j | Neo4j JDBC | +| cassandra | Apache Cassandra | Cassandra JDBC | +| bigquery | Google BigQuery | BigQuery JDBC | +| kylin | Apache Kylin | Kylin JDBC | +| sundb | SunDB | SunDB JDBC | +| tdengine | TDengine | taos-jdbcdriver(WebSocket,REST 兜底) | +| yashandb | 崖山 YashanDB | YashanDB JDBC | +| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent | +| iotdb | Apache IoTDB | IoTDB JDBC | +| etcd | etcd | jetcd | +| zookeeper | Apache ZooKeeper | Apache Curator | + + +## 多 JRE 支持 + +多数 Java agent 以 JRE 21 为目标。原生 agent(如 `oracle` 和 `xugu`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。 + +## 选择驱动实现语言 + +对于新 agent,只要存在成熟、许可证兼容的原生驱动,优先选择**原生(Go 或 Rust)驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE,可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。 + +- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/oracle-go`(go-ora)和 `drivers/xugu`。无需 JRE 下载与管理。 +- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。 + +原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时存在原生和 Java 路径,DBX 默认使用原生方案,仅将 Java 变体作为兼容兜底保留 —— 参见 `oracle`(go-ora 原生)与 `oracle-legacy` / `oracle-10g` 的共存方式。 + +## 构建 + +需要 JDK 21(Gradle toolchain 会自动下载)。 + +```bash +./gradlew shadowJar +(cd drivers/oracle-go && go build -o agent .) +(cd drivers/xugu && go build -o agent .) +``` + +产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/oracle-go` 和 `drivers/xugu` 构建。 + +### 本地 DBX 运行时测试 + +修改 `agents/drivers//` 下的 Java agent 或共享 Java agent 协议代码后,需重新构建目标 agent 并替换本地 DBX 应用使用的运行时 JAR: + +```bash +./gradlew ::shadowJar +cp ~/.dbx/agents/drivers//agent.jar ~/.dbx/agents/drivers//agent.jar.bak +cp agents/drivers//build/libs/*-all.jar ~/.dbx/agents/drivers//agent.jar +``` + +重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。 + +`oracle` 和 `xugu` 等原生 agent 使用驱动目录下的 `agent` 可执行文件而非 `agent.jar`。 + +## 版本管理 + +Agent 模块的版本记录在 [`versions.json`](versions.json) 中,遵循以下规则: + +- **修改现有驱动**:无需手动编辑 `versions.json`。发版 CI 会把每个 `drivers//` 目录与上一个 tag 做对比,对有变更的模块自动 bump patch 版本号(见 [`bump-agent-versions.mjs`](../.github/scripts/bump-agent-versions.mjs))。若改动的是共享运行时 `agents/common`,所有依赖它的模块会一并 bump。 +- **新增驱动**:在 `versions.json` 中新增一行,例如 `"rabbitmq": "0.1.0"`。CI 只 bump 文件里已存在的 key,所以新模块在登记到这里之前对版本管理完全不可见。同一次改动中,还要把模块加进 `settings.gradle` 并更新上方的支持表 —— `versions.json` 的 key 必须与 `settings.gradle` 声明的 agent 模块一致(不含 `common`、`test-support` 这类基础设施模块)。 + +## 开发 + +- Agent 编写指南:[docs/agent-authoring.md](docs/agent-authoring.md) +- JDBC agent 模板:[docs/examples/jdbc-agent-template](docs/examples/jdbc-agent-template) +- 发布检查清单:[docs/release-checklist.md](docs/release-checklist.md) + +## 架构 + +``` +DBX 主进程 (Rust/Tauri) + │ stdin/stdout (JSON-RPC 2.0) + ▼ +agent / java -jar dbx-agent-{type}.jar + │ + ▼ +原生驱动 / JDBC → 数据库 +``` + +## 许可证 + +[AGPL-3.0](https://github.com/t8y2/dbx/blob/main/LICENSE) diff --git a/agents/build.gradle b/agents/build.gradle new file mode 100644 index 0000000..5c9ca37 --- /dev/null +++ b/agents/build.gradle @@ -0,0 +1,54 @@ +plugins { + id 'com.gradleup.shadow' version '9.0.0-beta12' apply false +} + +def java8Projects = ['common', 'test-support'] as Set +def infrastructureProjects = ['common', 'test-support'] as Set +def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set +def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) } +def jdbcAgentProjects = agentProjects.findAll { !legacyStandaloneProjects.contains(it.name) } + +allprojects { + repositories { + mavenCentral() + } +} + +subprojects { + if (name == 'test-support') { + apply plugin: 'java-library' + } else { + apply plugin: 'java' + } + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(java8Projects.contains(name) ? 8 : 21) + } + } + + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4' + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } +} + +configure(agentProjects) { + apply plugin: 'com.gradleup.shadow' + + tasks.named('shadowJar') { + archiveBaseName = "dbx-agent-${project.name}" + archiveClassifier = '' + } +} + +configure(jdbcAgentProjects) { + dependencies { + implementation project(':common') + testImplementation project(':test-support') + } +} diff --git a/agents/common/build.gradle b/agents/common/build.gradle new file mode 100644 index 0000000..ee0d25b --- /dev/null +++ b/agents/common/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation 'com.google.code.gson:gson:2.12.1' +} diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java new file mode 100644 index 0000000..190db0d --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -0,0 +1,240 @@ +package com.dbx.agent; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.Driver; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { + private Connection connection; + private String configuredDatabase = ""; + + @Override + public final Connection getConnection() { + return connection; + } + + @Override + public final void connect(ConnectParams params) { + uncheckedVoid(() -> { + loadDriver(params); + connection = openConnection(params); + configuredDatabase = params.getDatabase(); + afterConnect(params, connection); + }); + } + + @Override + public final boolean testConnection(ConnectParams params) { + return unchecked(() -> { + loadDriver(params); + try (Connection conn = openConnection(params)) { + return conn.isValid(5); + } + }); + } + + private void loadDriver(ConnectParams params) throws Exception { + List driverPaths = params.getJdbc_driver_paths(); + String driverClass = params.getJdbc_driver_class(); + if (driverClass == null || driverClass.isEmpty()) { + driverClass = driverClass(); + } + if (driverPaths != null && !driverPaths.isEmpty()) { + List urls = new ArrayList<>(); + for (String path : driverPaths) { + urls.add(Paths.get(path).toUri().toURL()); + } + URLClassLoader loader = new URLClassLoader(urls.toArray(new URL[0]), getClass().getClassLoader()); + Driver driver = (Driver) Class.forName(driverClass, true, loader).getDeclaredConstructor().newInstance(); + DriverManager.registerDriver(new DriverShim(driver)); + } else { + Class.forName(driverClass); + } + } + + private static final class DriverShim implements Driver { + private final Driver driver; + + DriverShim(Driver driver) { + this.driver = driver; + } + + @Override + public Connection connect(String url, java.util.Properties info) throws java.sql.SQLException { + return driver.connect(url, info); + } + + @Override + public boolean acceptsURL(String url) throws java.sql.SQLException { + return driver.acceptsURL(url); + } + + @Override + public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info) throws java.sql.SQLException { + return driver.getPropertyInfo(url, info); + } + + @Override + public int getMajorVersion() { + return driver.getMajorVersion(); + } + + @Override + public int getMinorVersion() { + return driver.getMinorVersion(); + } + + @Override + public boolean jdbcCompliant() { + return driver.jdbcCompliant(); + } + + @Override + public java.util.logging.Logger getParentLogger() throws java.sql.SQLFeatureNotSupportedException { + return driver.getParentLogger(); + } + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + Connection conn = requireConnected(); + uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs())); + return JdbcExecutor.INSTANCE.execute( + conn, + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + resultValueReader() + ); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + Connection conn = requireConnected(); + uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs())); + return JdbcExecutor.INSTANCE.executePage( + conn, + sql, + schema, + this::setSchemaSQL, + options, + resultValueReader() + ); + } + + @Override + public QueryPageResult fetchQueryPage(String sessionId, int pageSize) { + return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize); + } + + @Override + public boolean closeQuerySession(String sessionId) { + return JdbcExecutor.INSTANCE.closeQuerySession(sessionId); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + Connection conn = requireConnected(); + uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs())); + return JdbcExecutor.INSTANCE.startTableRead( + conn, + sql, + schema, + this::setSchemaSQL, + options, + resultValueReader() + ); + } + + @Override + public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) { + return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize); + } + + @Override + public boolean closeTableReadSession(String sessionId) { + return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId); + } + + @Override + public QueryResult executeTransaction(List statements, String schema) { + return TransactionExecutor.executeUpdateStatements(requireConnected(), statements, schema, this::setSchemaSQL); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + protected abstract String driverClass(); + + protected abstract String buildJdbcUrl(ConnectParams params); + + protected Connection openConnection(ConnectParams params) throws Exception { + return DriverManager.getConnection(buildJdbcUrl(params), params.getUsername(), params.getPassword()); + } + + protected void afterConnect(ConnectParams params, Connection connection) throws Exception { + } + + protected void beforeQueryExecution(Connection connection, int timeoutSecs) throws Exception { + } + + protected String getConfiguredDatabase() { + return configuredDatabase; + } + + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value; + switch (sqlType) { + case Types.BIGINT: + value = rs.getLong(index); + break; + case Types.INTEGER: + case Types.SMALLINT: + case Types.TINYINT: + value = rs.getInt(index); + break; + case Types.FLOAT: + case Types.REAL: + value = rs.getFloat(index); + break; + case Types.DOUBLE: + value = rs.getDouble(index); + break; + case Types.DECIMAL: + case Types.NUMERIC: + value = rs.getBigDecimal(index); + break; + case Types.BOOLEAN: + case Types.BIT: + value = rs.getBoolean(index); + break; + default: + value = rs.getString(index); + break; + } + return rs.wasNull() ? null : value; + }); + } + + protected JdbcExecutor.ResultValueReader resultValueReader() { + return this::resultValue; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java new file mode 100644 index 0000000..2dc6724 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -0,0 +1,163 @@ +package com.dbx.agent; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public final class AgentProtocol { + public static final int PROTOCOL_VERSION = 1; + + public static final String METHOD_HANDSHAKE = "handshake"; + public static final String METHOD_CONNECT = "connect"; + public static final String METHOD_TEST_CONNECTION = "test_connection"; + public static final String METHOD_VALIDATE_CONNECTION = "validate_connection"; + public static final String METHOD_LIST_DATABASES = "list_databases"; + public static final String METHOD_LIST_SCHEMAS = "list_schemas"; + public static final String METHOD_LIST_TABLES = "list_tables"; + public static final String METHOD_LIST_OBJECTS = "list_objects"; + public static final String METHOD_LIST_DATA_TYPES = "list_data_types"; + public static final String METHOD_COMPLETION_ASSISTANT_SEARCH_V1 = "completion_assistant_search_v1"; + public static final String METHOD_GET_OBJECT_SOURCE = "get_object_source"; + public static final String METHOD_GET_TABLE_DDL = "get_table_ddl"; + public static final String METHOD_GET_COLUMNS = "get_columns"; + public static final String METHOD_LIST_INDEXES = "list_indexes"; + public static final String METHOD_LIST_FOREIGN_KEYS = "list_foreign_keys"; + public static final String METHOD_LIST_TRIGGERS = "list_triggers"; + public static final String METHOD_EXECUTE_QUERY = "execute_query"; + public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page"; + public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page"; + public static final String METHOD_CLOSE_QUERY_SESSION = "close_query_session"; + public static final String METHOD_START_TABLE_READ = "start_table_read"; + public static final String METHOD_FETCH_TABLE_READ_PAGE = "fetch_table_read_page"; + public static final String METHOD_CLOSE_TABLE_READ_SESSION = "close_table_read_session"; + public static final String METHOD_GET_EXPLAIN_INFO = "get_explain_info"; + public static final String METHOD_EXECUTE_BATCH = "execute_batch"; + public static final String METHOD_EXECUTE_TRANSACTION = "execute_transaction"; + public static final String METHOD_DISCONNECT = "disconnect"; + public static final String METHOD_SHUTDOWN = "shutdown"; + + public static final String MONGO_METHOD_LIST_DATABASES = "list_databases"; + public static final String MONGO_METHOD_LIST_COLLECTIONS = "list_collections"; + public static final String MONGO_METHOD_FIND_DOCUMENTS = "find_documents"; + /** + * MongoDB read path that returns documents as relaxed Extended JSON for transfer. + */ + public static final String MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON = "find_documents_extended_json"; + public static final String MONGO_METHOD_SERVER_VERSION = "server_version"; + public static final String MONGO_METHOD_CREATE_INDEX = "create_index"; + public static final String MONGO_METHOD_DROP_INDEXES = "drop_indexes"; + public static final String MONGO_METHOD_DROP_COLLECTION = "drop_collection"; + public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document"; + public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document"; + public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents"; + public static final String MONGO_METHOD_DELETE_DOCUMENT = "delete_document"; + public static final String MONGO_METHOD_DELETE_DOCUMENTS = "delete_documents"; + + public static final String KV_METHOD_LIST_PREFIX = "kv_list_prefix"; + public static final String KV_METHOD_GET = "kv_get"; + public static final String KV_METHOD_PUT = "kv_put"; + public static final String KV_METHOD_DELETE = "kv_delete"; + + public static final String CAPABILITY_CONNECT = "connect"; + public static final String CAPABILITY_TEST_CONNECTION = "test_connection"; + public static final String CAPABILITY_METADATA = "metadata"; + public static final String CAPABILITY_QUERY = "query"; + public static final String CAPABILITY_PAGED_QUERY = "paged_query"; + public static final String CAPABILITY_TRANSACTION = "transaction"; + public static final String CAPABILITY_DDL = "ddl"; + public static final String CAPABILITY_KV = "kv"; + + public static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( + CAPABILITY_CONNECT, + CAPABILITY_TEST_CONNECTION, + CAPABILITY_METADATA, + CAPABILITY_QUERY, + CAPABILITY_PAGED_QUERY, + CAPABILITY_TRANSACTION, + CAPABILITY_DDL + )); + + public static final List ALL_CAPABILITIES = Collections.unmodifiableList(Arrays.asList( + CAPABILITY_CONNECT, + CAPABILITY_TEST_CONNECTION, + CAPABILITY_METADATA, + CAPABILITY_QUERY, + CAPABILITY_PAGED_QUERY, + CAPABILITY_TRANSACTION, + CAPABILITY_DDL, + CAPABILITY_KV + )); + + public static final List COMMON_METHODS = Collections.unmodifiableList(Arrays.asList( + METHOD_HANDSHAKE, + METHOD_CONNECT, + METHOD_TEST_CONNECTION, + METHOD_VALIDATE_CONNECTION, + METHOD_LIST_DATABASES, + METHOD_LIST_SCHEMAS, + METHOD_LIST_TABLES, + METHOD_LIST_OBJECTS, + METHOD_LIST_DATA_TYPES, + METHOD_COMPLETION_ASSISTANT_SEARCH_V1, + METHOD_GET_OBJECT_SOURCE, + METHOD_GET_TABLE_DDL, + METHOD_GET_COLUMNS, + METHOD_LIST_INDEXES, + METHOD_LIST_FOREIGN_KEYS, + METHOD_LIST_TRIGGERS, + METHOD_EXECUTE_QUERY, + METHOD_EXECUTE_QUERY_PAGE, + METHOD_FETCH_QUERY_PAGE, + METHOD_CLOSE_QUERY_SESSION, + METHOD_START_TABLE_READ, + METHOD_FETCH_TABLE_READ_PAGE, + METHOD_CLOSE_TABLE_READ_SESSION, + METHOD_GET_EXPLAIN_INFO, + METHOD_EXECUTE_BATCH, + METHOD_EXECUTE_TRANSACTION, + METHOD_DISCONNECT, + METHOD_SHUTDOWN + )); + + public static final List MONGO_LEGACY_METHODS = Collections.unmodifiableList(Arrays.asList( + MONGO_METHOD_LIST_DATABASES, + MONGO_METHOD_LIST_COLLECTIONS, + MONGO_METHOD_FIND_DOCUMENTS, + MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON, + MONGO_METHOD_SERVER_VERSION, + MONGO_METHOD_CREATE_INDEX, + MONGO_METHOD_DROP_INDEXES, + MONGO_METHOD_DROP_COLLECTION, + MONGO_METHOD_INSERT_DOCUMENT, + MONGO_METHOD_UPDATE_DOCUMENT, + MONGO_METHOD_UPDATE_DOCUMENTS, + MONGO_METHOD_DELETE_DOCUMENT, + MONGO_METHOD_DELETE_DOCUMENTS + )); + + public static final List KV_METHODS = Collections.unmodifiableList(Arrays.asList( + KV_METHOD_LIST_PREFIX, + KV_METHOD_GET, + KV_METHOD_PUT, + KV_METHOD_DELETE + )); + + private AgentProtocol() { + } + + public static HandshakeResult handshakeResult() { + return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, CAPABILITIES); + } + + public static final class HandshakeResult { + private final int protocolVersion; + private final int agentProtocolVersion; + private final List capabilities; + + private HandshakeResult(int protocolVersion, int agentProtocolVersion, List capabilities) { + this.protocolVersion = protocolVersion; + this.agentProtocolVersion = agentProtocolVersion; + this.capabilities = capabilities; + } + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java new file mode 100644 index 0000000..13b3531 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/BaseDatabaseAgent.java @@ -0,0 +1,134 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public abstract class BaseDatabaseAgent implements DatabaseAgent { + @Override + public List listObjects(String schema) { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + return result; + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + throw new UnsupportedOperationException("Object source is not supported"); + } + + @Override + public CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) { + throw new UnsupportedOperationException("Completion assistant search is not supported by this agent"); + } + + @Override + public String getTableDdl(String schema, String table) { + List indexes; + try { + indexes = listIndexes(schema, table); + } catch (RuntimeException e) { + indexes = Collections.emptyList(); + } + + List foreignKeys; + try { + foreignKeys = listForeignKeys(schema, table); + } catch (RuntimeException e) { + foreignKeys = Collections.emptyList(); + } + + return DatabaseAgent.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + Connection conn = requireConnected(); + return JdbcExecutor.INSTANCE.executePage( + conn, + sql, + schema, + this::setSchemaSQL, + options, + JdbcExecutor.INSTANCE::defaultResultValue + ); + } + + @Override + public QueryPageResult fetchQueryPage(String sessionId, int pageSize) { + return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize); + } + + @Override + public boolean closeQuerySession(String sessionId) { + return JdbcExecutor.INSTANCE.closeQuerySession(sessionId); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.startTableRead( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + JdbcExecutor.INSTANCE::defaultResultValue + ); + } + + @Override + public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) { + return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize); + } + + @Override + public boolean closeTableReadSession(String sessionId) { + return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId); + } + + @Override + public QueryResult executeTransaction(List statements, String schema) { + return TransactionExecutor.executeUpdateStatements(requireConnected(), statements, schema, this::setSchemaSQL); + } + + @Override + public QueryResult executeBatch(List statements, String schema) { + return BatchExecutor.executeBatchStatements(requireConnected(), statements, schema, this::setSchemaSQL); + } + + protected Connection requireConnected() { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return conn; + } + + protected static T unchecked(ThrowingSupplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected static void uncheckedVoid(ThrowingRunnable runnable) { + unchecked(() -> { + runnable.run(); + return null; + }); + } + + protected interface ThrowingSupplier { + T get() throws Exception; + } + + protected interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/BatchExecutor.java b/agents/common/src/main/java/com/dbx/agent/BatchExecutor.java new file mode 100644 index 0000000..134ada0 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/BatchExecutor.java @@ -0,0 +1,98 @@ +package com.dbx.agent; + +import java.sql.BatchUpdateException; +import java.sql.Connection; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +public final class BatchExecutor { + private BatchExecutor() { + } + + public static QueryResult executeBatchStatements( + Connection conn, + List statements, + String schema, + Function setSchemaSql + ) { + return unchecked(() -> { + long start = System.currentTimeMillis(); + applySchema(conn, schema, setSchemaSql); + long totalAffected = 0; + int statementCount = 0; + try (Statement stmt = conn.createStatement()) { + for (String statement : statements) { + String trimmed = JdbcExecutor.trimSql(statement); + if (trimmed.isEmpty()) { + continue; + } + stmt.addBatch(trimmed); + statementCount++; + } + if (statementCount > 0) { + totalAffected = affectedRows(executeBatch(stmt)); + } + } catch (BatchUpdateException e) { + long[] counts = e.getLargeUpdateCounts(); + int failedIndex = counts == null ? 1 : counts.length + 1; + throw new RuntimeException("Statement " + failedIndex + " failed: " + e.getMessage(), e); + } + return new QueryResult( + Collections.emptyList(), + Collections.emptyList(), + totalAffected, + System.currentTimeMillis() - start, + false + ); + }); + } + + private static long affectedRows(long[] updateCounts) { + long total = 0; + if (updateCounts == null) { + return total; + } + for (long count : updateCounts) { + if (count >= 0) { + total += count; + } else if (count == Statement.SUCCESS_NO_INFO) { + total += 1; + } + } + return total; + } + + private static long[] executeBatch(Statement stmt) throws Exception { + try { + return stmt.executeLargeBatch(); + } catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) { + int[] counts = stmt.executeBatch(); + long[] largeCounts = new long[counts.length]; + for (int i = 0; i < counts.length; i++) { + largeCounts[i] = counts[i]; + } + return largeCounts; + } + } + + private static void applySchema(Connection conn, String schema, Function setSchemaSql) throws Exception { + JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql); + } + + private static T unchecked(ThrowingSupplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/CheckConstraintInfo.java b/agents/common/src/main/java/com/dbx/agent/CheckConstraintInfo.java new file mode 100644 index 0000000..a38d7f6 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CheckConstraintInfo.java @@ -0,0 +1,19 @@ +package com.dbx.agent; + +public final class CheckConstraintInfo { + private final String name; + private final String definition; + + public CheckConstraintInfo(String name, String definition) { + this.name = name; + this.definition = definition; + } + + public String getName() { + return name; + } + + public String getDefinition() { + return definition; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ColumnInfo.java b/agents/common/src/main/java/com/dbx/agent/ColumnInfo.java new file mode 100644 index 0000000..d65df3b --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ColumnInfo.java @@ -0,0 +1,176 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class ColumnInfo { + private String name; + private String data_type; + private boolean is_nullable; + private String column_default; + private boolean is_primary_key; + private String extra; + private String comment; + private Integer numeric_precision; + private Integer numeric_scale; + private Integer character_maximum_length; + + public ColumnInfo() { + this("", "", true, null, false); + } + + public ColumnInfo(String name, String data_type, boolean is_nullable, String column_default, boolean is_primary_key) { + this(name, data_type, is_nullable, column_default, is_primary_key, null, null, null, null, null); + } + + public ColumnInfo( + String name, + String data_type, + boolean is_nullable, + String column_default, + boolean is_primary_key, + String extra, + String comment, + Integer numeric_precision, + Integer numeric_scale, + Integer character_maximum_length + ) { + this.name = name; + this.data_type = data_type; + this.is_nullable = is_nullable; + this.column_default = column_default; + this.is_primary_key = is_primary_key; + this.extra = extra; + this.comment = comment; + this.numeric_precision = numeric_precision; + this.numeric_scale = numeric_scale; + this.character_maximum_length = character_maximum_length; + } + + public String getName() { + return name; + } + + public String getData_type() { + return data_type; + } + + public boolean getIs_nullable() { + return is_nullable; + } + + public String getColumn_default() { + return column_default; + } + + public boolean getIs_primary_key() { + return is_primary_key; + } + + public String getExtra() { + return extra; + } + + public String getComment() { + return comment; + } + + public Integer getNumeric_precision() { + return numeric_precision; + } + + public Integer getNumeric_scale() { + return numeric_scale; + } + + public Integer getCharacter_maximum_length() { + return character_maximum_length; + } + + public void setName(String name) { + this.name = name; + } + + public void setData_type(String data_type) { + this.data_type = data_type; + } + + public void setIs_nullable(boolean is_nullable) { + this.is_nullable = is_nullable; + } + + public void setColumn_default(String column_default) { + this.column_default = column_default; + } + + public void setIs_primary_key(boolean is_primary_key) { + this.is_primary_key = is_primary_key; + } + + public void setExtra(String extra) { + this.extra = extra; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public void setNumeric_precision(Integer numeric_precision) { + this.numeric_precision = numeric_precision; + } + + public void setNumeric_scale(Integer numeric_scale) { + this.numeric_scale = numeric_scale; + } + + public void setCharacter_maximum_length(Integer character_maximum_length) { + this.character_maximum_length = character_maximum_length; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ColumnInfo)) return false; + ColumnInfo that = (ColumnInfo) other; + return is_nullable == that.is_nullable + && is_primary_key == that.is_primary_key + && Objects.equals(name, that.name) + && Objects.equals(data_type, that.data_type) + && Objects.equals(column_default, that.column_default) + && Objects.equals(extra, that.extra) + && Objects.equals(comment, that.comment) + && Objects.equals(numeric_precision, that.numeric_precision) + && Objects.equals(numeric_scale, that.numeric_scale) + && Objects.equals(character_maximum_length, that.character_maximum_length); + } + + @Override + public int hashCode() { + return Objects.hash( + name, + data_type, + is_nullable, + column_default, + is_primary_key, + extra, + comment, + numeric_precision, + numeric_scale, + character_maximum_length + ); + } + + @Override + public String toString() { + return "ColumnInfo(name=" + name + + ", data_type=" + data_type + + ", is_nullable=" + is_nullable + + ", column_default=" + column_default + + ", is_primary_key=" + is_primary_key + + ", extra=" + extra + + ", comment=" + comment + + ", numeric_precision=" + numeric_precision + + ", numeric_scale=" + numeric_scale + + ", character_maximum_length=" + character_maximum_length + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantCandidate.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantCandidate.java new file mode 100644 index 0000000..d541a49 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantCandidate.java @@ -0,0 +1,41 @@ +package com.dbx.agent; + +public final class CompletionAssistantCandidate { + private final String name; + private final CompletionAssistantCandidateKind kind; + private final String database; + private final String schema; + private final String parent_schema; + private final String parent_name; + private final String comment; + private final String data_type; + + public CompletionAssistantCandidate( + String name, + CompletionAssistantCandidateKind kind, + String database, + String schema, + String parentSchema, + String parentName, + String comment, + String dataType + ) { + this.name = name; + this.kind = kind; + this.database = database; + this.schema = schema; + this.parent_schema = parentSchema; + this.parent_name = parentName; + this.comment = comment; + this.data_type = dataType; + } + + public String getName() { return name; } + public CompletionAssistantCandidateKind getKind() { return kind; } + public String getDatabase() { return database; } + public String getSchema() { return schema; } + public String getParent_schema() { return parent_schema; } + public String getParent_name() { return parent_name; } + public String getComment() { return comment; } + public String getData_type() { return data_type; } +} diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantCandidateKind.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantCandidateKind.java new file mode 100644 index 0000000..9e2a02d --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantCandidateKind.java @@ -0,0 +1,22 @@ +package com.dbx.agent; + +import com.google.gson.annotations.SerializedName; + +public enum CompletionAssistantCandidateKind { + @SerializedName("database") + DATABASE, + @SerializedName("schema") + SCHEMA, + @SerializedName("table") + TABLE, + @SerializedName("view") + VIEW, + @SerializedName("procedure") + PROCEDURE, + @SerializedName("function") + FUNCTION, + @SerializedName("column") + COLUMN, + @SerializedName("object") + OBJECT +} diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantMatchMode.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantMatchMode.java new file mode 100644 index 0000000..6f44850 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantMatchMode.java @@ -0,0 +1,10 @@ +package com.dbx.agent; + +import com.google.gson.annotations.SerializedName; + +public enum CompletionAssistantMatchMode { + @SerializedName("prefix") + PREFIX, + @SerializedName("contains") + CONTAINS +} diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantObjectKind.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantObjectKind.java new file mode 100644 index 0000000..ec7bbd3 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantObjectKind.java @@ -0,0 +1,22 @@ +package com.dbx.agent; + +import com.google.gson.annotations.SerializedName; + +public enum CompletionAssistantObjectKind { + @SerializedName("database") + DATABASE, + @SerializedName("schema") + SCHEMA, + @SerializedName("table") + TABLE, + @SerializedName("view") + VIEW, + @SerializedName("routine") + ROUTINE, + @SerializedName("procedure") + PROCEDURE, + @SerializedName("function") + FUNCTION, + @SerializedName("column") + COLUMN +} diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantRequest.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantRequest.java new file mode 100644 index 0000000..a304f51 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantRequest.java @@ -0,0 +1,34 @@ +package com.dbx.agent; + +import java.util.ArrayList; +import java.util.List; + +public final class CompletionAssistantRequest { + private String connection_id; + private String database; + private String schema; + private List object_kinds = new ArrayList<>(); + private String mask = ""; + private boolean case_sensitive; + private boolean global_search; + private Integer max_results; + private boolean search_in_comments; + private boolean search_in_definitions; + private String parent_schema; + private String parent_name; + private CompletionAssistantMatchMode match_mode; + + public String getConnection_id() { return connection_id; } + public String getDatabase() { return database == null ? "" : database; } + public String getSchema() { return schema; } + public List getObject_kinds() { return object_kinds == null ? new ArrayList<>() : object_kinds; } + public String getMask() { return mask == null ? "" : mask; } + public boolean getCase_sensitive() { return case_sensitive; } + public boolean getGlobal_search() { return global_search; } + public Integer getMax_results() { return max_results; } + public boolean getSearch_in_comments() { return search_in_comments; } + public boolean getSearch_in_definitions() { return search_in_definitions; } + public String getParent_schema() { return parent_schema; } + public String getParent_name() { return parent_name; } + public CompletionAssistantMatchMode getMatch_mode() { return match_mode; } +} diff --git a/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java new file mode 100644 index 0000000..c20c540 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/CompletionAssistantResponse.java @@ -0,0 +1,19 @@ +package com.dbx.agent; + +import java.util.List; + +public final class CompletionAssistantResponse { + private final List candidates; + private final boolean incomplete; + private final boolean fallback_used; + + public CompletionAssistantResponse(List candidates, boolean incomplete, boolean fallbackUsed) { + this.candidates = candidates; + this.incomplete = incomplete; + this.fallback_used = fallbackUsed; + } + + public List getCandidates() { return candidates; } + public boolean getIncomplete() { return incomplete; } + public boolean getFallback_used() { return fallback_used; } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ConfiguredJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/ConfiguredJdbcAgent.java new file mode 100644 index 0000000..7d75e41 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ConfiguredJdbcAgent.java @@ -0,0 +1,222 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public abstract class ConfiguredJdbcAgent extends AbstractJdbcAgent { + private final JdbcAgentProfile profile; + private String configuredDatabase = ""; + + protected ConfiguredJdbcAgent(JdbcAgentProfile profile) { + this.profile = profile; + } + + public JdbcAgentProfile getProfile() { + return profile; + } + + @Override + protected String driverClass() { + return profile.getDriverClass(); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return profile.buildUrl(params); + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) { + configuredDatabase = params.getDatabase(); + } + + @Override + public List listDatabases() { + return StandardJdbcMetadata.INSTANCE.listDatabases(requireConnection(), configuredDatabase); + } + + @Override + public List listSchemas() { + return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), profile); + } + + @Override + public List listTables(String schema) { + return StandardJdbcMetadata.INSTANCE.listTables(requireConnection(), profile, configuredDatabase, schema); + } + + @Override + public List listTables(String schema, List objectTypes) { + return listTables(schema, new MetadataListConstraints(null, null, null, objectTypes)); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + return StandardJdbcMetadata.INSTANCE.listTables( + requireConnection(), + profile, + configuredDatabase, + schema, + constraints + ); + } + + @Override + public List listObjects(String schema) { + return StandardJdbcMetadata.INSTANCE.listObjects( + requireConnection(), + profile, + configuredDatabase, + schema, + MetadataListConstraints.NONE + ); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + return StandardJdbcMetadata.INSTANCE.listObjects( + requireConnection(), + profile, + configuredDatabase, + schema, + constraints + ); + } + + @Override + public List listDataTypes() { + return StandardJdbcMetadata.INSTANCE.listDataTypes(requireConnection()); + } + + @Override + public CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) { + return StandardJdbcMetadata.INSTANCE.completionAssistantSearch(requireConnection(), profile, configuredDatabase, request); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + throw new UnsupportedOperationException("Object source is not supported"); + } + + @Override + public List getColumns(String schema, String table) { + return StandardJdbcMetadata.INSTANCE.getColumns(requireConnection(), profile, configuredDatabase, schema, table); + } + + @Override + public List listIndexes(String schema, String table) { + return StandardJdbcMetadata.INSTANCE.listIndexes(requireConnection(), schema, table); + } + + @Override + public List listForeignKeys(String schema, String table) { + return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnection(), schema, table); + } + + @Override + public List listTriggers(String schema, String table) { + return StandardJdbcMetadata.INSTANCE.listTriggers(schema, table); + } + + @Override + public String getTableDdl(String schema, String table) { + List indexes; + try { + indexes = listIndexes(schema, table); + } catch (RuntimeException e) { + indexes = Collections.emptyList(); + } + + List foreignKeys; + try { + foreignKeys = listForeignKeys(schema, table); + } catch (RuntimeException e) { + foreignKeys = Collections.emptyList(); + } + + return DatabaseAgent.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys); + } + + @Override + public String setSchemaSQL(String schema) { + if (profile.getSkipExecutionContext()) { + return ""; + } + return StandardJdbcMetadata.schemaSwitchSql(requireConnection(), profile, schema); + } + + protected Connection requireConnection() { + return requireConnected(); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return super.resultValue(rs, index, sqlType); + } + + private static String normalizeTableType(String type) { + if (type == null || type.trim().isEmpty()) { + return "TABLE"; + } + if ("BASE TABLE".equals(type.toUpperCase(Locale.ROOT))) { + return "TABLE"; + } + return type; + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static Integer characterLength(ResultSet rs) throws Exception { + String typeName = rs.getString("TYPE_NAME"); + if (typeName == null) { + return null; + } + String normalized = typeName.toLowerCase(Locale.ROOT); + if (!normalized.contains("char") && !normalized.contains("text")) { + return null; + } + return intOrNull(rs, "COLUMN_SIZE"); + } + + private static String blankToNull(String value) { + return value == null || value.trim().isEmpty() ? null : value; + } + + private static void addNonBlank(Set values, String value) { + if (value != null && !value.trim().isEmpty()) { + values.add(value); + } + } + + private static final class IndexColumn { + private final int ordinal; + private final String name; + + private IndexColumn(int ordinal, String name) { + this.ordinal = ordinal; + this.name = name; + } + + private int getOrdinal() { + return ordinal; + } + + private String getName() { + return name; + } + } + +} diff --git a/agents/common/src/main/java/com/dbx/agent/ConnectParams.java b/agents/common/src/main/java/com/dbx/agent/ConnectParams.java new file mode 100644 index 0000000..3e4639a --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ConnectParams.java @@ -0,0 +1,243 @@ +package com.dbx.agent; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class ConnectParams { + private String host; + private int port; + private String database; + private String username; + private String password; + private String url_params; + private String connection_string; + private boolean mysql_compat_mode; + private String jdbc_driver_class; + private List jdbc_driver_paths; + private boolean ssl; + private String ca_cert_path; + private String client_cert_path; + private String client_key_path; + private String gbase_server; + private String informix_server; + + public ConnectParams() { + this("", 0, "", "", "", "", "", false, "", Collections.emptyList()); + } + + public ConnectParams( + String host, + int port, + String database, + String username, + String password, + String url_params, + String connection_string, + boolean mysql_compat_mode + ) { + this(host, port, database, username, password, url_params, connection_string, mysql_compat_mode, "", Collections.emptyList()); + } + + public ConnectParams( + String host, + int port, + String database, + String username, + String password, + String url_params, + String connection_string, + boolean mysql_compat_mode, + String jdbc_driver_class, + List jdbc_driver_paths + ) { + this.host = host; + this.port = port; + this.database = database; + this.username = username; + this.password = password; + this.url_params = url_params; + this.connection_string = connection_string; + this.mysql_compat_mode = mysql_compat_mode; + this.jdbc_driver_class = jdbc_driver_class; + this.jdbc_driver_paths = jdbc_driver_paths; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public String getDatabase() { + return database; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getUrl_params() { + return url_params; + } + + public String getConnection_string() { + return connection_string; + } + + public void setHost(String host) { + this.host = host; + } + + public void setPort(int port) { + this.port = port; + } + + public void setDatabase(String database) { + this.database = database; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setUrl_params(String url_params) { + this.url_params = url_params; + } + + public void setConnection_string(String connection_string) { + this.connection_string = connection_string; + } + + public boolean isMysql_compat_mode() { + return mysql_compat_mode; + } + + public void setMysql_compat_mode(boolean mysql_compat_mode) { + this.mysql_compat_mode = mysql_compat_mode; + } + + public String getJdbc_driver_class() { + return jdbc_driver_class; + } + + public void setJdbc_driver_class(String jdbc_driver_class) { + this.jdbc_driver_class = jdbc_driver_class; + } + + public List getJdbc_driver_paths() { + return jdbc_driver_paths; + } + + public void setJdbc_driver_paths(List jdbc_driver_paths) { + this.jdbc_driver_paths = jdbc_driver_paths; + } + + public boolean isSsl() { + return ssl; + } + + public void setSsl(boolean ssl) { + this.ssl = ssl; + } + + public String getCa_cert_path() { + return ca_cert_path; + } + + public void setCa_cert_path(String ca_cert_path) { + this.ca_cert_path = ca_cert_path; + } + + public String getClient_cert_path() { + return client_cert_path; + } + + public void setClient_cert_path(String client_cert_path) { + this.client_cert_path = client_cert_path; + } + + public String getClient_key_path() { + return client_key_path; + } + + public void setClient_key_path(String client_key_path) { + this.client_key_path = client_key_path; + } + + public String getGbase_server() { + return gbase_server; + } + + public void setGbase_server(String gbase_server) { + this.gbase_server = gbase_server; + } + + public String getInformix_server() { + return informix_server; + } + + public void setInformix_server(String informix_server) { + this.informix_server = informix_server; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ConnectParams)) return false; + ConnectParams that = (ConnectParams) other; + return port == that.port + && Objects.equals(host, that.host) + && Objects.equals(database, that.database) + && Objects.equals(username, that.username) + && Objects.equals(password, that.password) + && Objects.equals(url_params, that.url_params) + && Objects.equals(connection_string, that.connection_string) + && mysql_compat_mode == that.mysql_compat_mode + && Objects.equals(jdbc_driver_class, that.jdbc_driver_class) + && Objects.equals(jdbc_driver_paths, that.jdbc_driver_paths) + && ssl == that.ssl + && Objects.equals(ca_cert_path, that.ca_cert_path) + && Objects.equals(client_cert_path, that.client_cert_path) + && Objects.equals(client_key_path, that.client_key_path) + && Objects.equals(gbase_server, that.gbase_server) + && Objects.equals(informix_server, that.informix_server); + } + + @Override + public int hashCode() { + return Objects.hash(host, port, database, username, password, url_params, connection_string, + mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server); + } + + @Override + public String toString() { + return "ConnectParams(host=" + host + + ", port=" + port + + ", database=" + database + + ", username=" + username + + ", password=" + password + + ", url_params=" + url_params + + ", connection_string=" + connection_string + + ", mysql_compat_mode=" + mysql_compat_mode + + ", jdbc_driver_class=" + jdbc_driver_class + + ", jdbc_driver_paths=" + jdbc_driver_paths + + ", ssl=" + ssl + + ", ca_cert_path=" + ca_cert_path + + ", client_cert_path=" + client_cert_path + + ", client_key_path=" + client_key_path + + ", gbase_server=" + gbase_server + + ", informix_server=" + informix_server + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java new file mode 100644 index 0000000..5e1b9a8 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -0,0 +1,211 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public interface DatabaseAgent { + void connect(ConnectParams params); + + boolean testConnection(ConnectParams params); + + List listDatabases(); + + List listSchemas(); + + default List listSchemas(List visibleSchemas) { + if (visibleSchemas == null) { + return listSchemas(); + } + Set visible = new HashSet<>(visibleSchemas); + return listSchemas().stream().filter(visible::contains).collect(Collectors.toList()); + } + + List listTables(String schema); + + default List listTables(String schema, List objectTypes) { + return listTables(schema); + } + + default List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + // Keep old driver overrides for object type filtering, then apply the new optional constraints locally. + return normalized.filterTables(listTables(schema, normalized.getObjectTypes())); + } + + default List listObjects(String schema) { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + return result; + } + + default List listObjects(String schema, MetadataListConstraints constraints) { + return MetadataListConstraints.orNone(constraints).filterObjects(listObjects(schema)); + } + + default List listDataTypes() { + return Collections.emptyList(); + } + + default CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) { + throw new UnsupportedOperationException("Completion assistant search is not supported by this agent"); + } + + List getColumns(String schema, String table); + + default ObjectSource getObjectSource(String schema, String name, String objectType) { + throw new UnsupportedOperationException("Object source is not supported"); + } + + default String getTableDdl(String schema, String table) { + List indexes; + try { + indexes = listIndexes(schema, table); + } catch (RuntimeException e) { + indexes = Collections.emptyList(); + } + + List foreignKeys; + try { + foreignKeys = listForeignKeys(schema, table); + } catch (RuntimeException e) { + foreignKeys = Collections.emptyList(); + } + + return buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys); + } + + List listIndexes(String schema, String table); + + List listForeignKeys(String schema, String table); + + List listTriggers(String schema, String table); + + default QueryResult executeQuery(String sql, String schema) { + return executeQuery(sql, schema, new ExecuteQueryOptions()); + } + + QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options); + + default QueryPageResult executeQueryPage(String sql, String schema) { + return executeQueryPage(sql, schema, new QueryPageOptions()); + } + + default QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return JdbcExecutor.INSTANCE.executePage( + conn, + sql, + schema, + this::setSchemaSQL, + options, + JdbcExecutor.INSTANCE::defaultResultValue + ); + } + + default QueryPageResult fetchQueryPage(String sessionId, int pageSize) { + return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize); + } + + default boolean closeQuerySession(String sessionId) { + return JdbcExecutor.INSTANCE.closeQuerySession(sessionId); + } + + default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return JdbcExecutor.INSTANCE.startTableRead( + conn, + sql, + schema, + this::setSchemaSQL, + options, + JdbcExecutor.INSTANCE::defaultResultValue + ); + } + + default QueryPageResult fetchTableReadPage(String sessionId, int pageSize) { + return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize); + } + + default boolean closeTableReadSession(String sessionId) { + return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId); + } + + /** + * Get DM execution plan. Supports two modes: + * mode="explain" (default) — direct plan, no execution + * mode="autotrace" — enable MONITOR_SQL_EXEC, execute SQL, then get plan with actual stats + * @return plan text + */ + default String getExplainInfo(String sql, String database, String schema, int timeoutSecs, String mode) { + throw new UnsupportedOperationException("getExplainInfo is not supported by this agent"); + } + + void disconnect(); + + Connection getConnection(); + + default QueryResult executeTransaction(List statements, String schema) { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return TransactionExecutor.executeUpdateStatements(conn, statements, schema, this::setSchemaSQL); + } + + default QueryResult executeBatch(List statements, String schema) { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return BatchExecutor.executeBatchStatements(conn, statements, schema, this::setSchemaSQL); + } + + default String setSchemaSQL(String schema) { + return "SET SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + static String buildTableDdl( + String schema, + String table, + List columns, + List indexes, + List foreignKeys + ) { + return DdlBuilder.buildTableDdl(schema, table, columns, indexes, foreignKeys); + } + + static String trimSql(String sql) { + String trimmed = sql.trim(); + while (trimmed.endsWith(";")) { + trimmed = trimmed.substring(0, trimmed.length() - 1).trim(); + } + return trimmed; + } + + static T unchecked(ThrowingSupplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseInfo.java b/agents/common/src/main/java/com/dbx/agent/DatabaseInfo.java new file mode 100644 index 0000000..e2c2f12 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseInfo.java @@ -0,0 +1,41 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class DatabaseInfo { + private String name; + + public DatabaseInfo() { + this(""); + } + + public DatabaseInfo(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof DatabaseInfo)) return false; + DatabaseInfo that = (DatabaseInfo) other; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return "DatabaseInfo(name=" + name + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java b/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java new file mode 100644 index 0000000..b03ef7e --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/DdlBuilder.java @@ -0,0 +1,228 @@ +package com.dbx.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public final class DdlBuilder { + private DdlBuilder() { + } + + public static String buildTableDdl( + String schema, + String table, + List columns, + List indexes, + List foreignKeys + ) { + return buildTableDdl(schema, table, columns, indexes, foreignKeys, false, false); + } + + public static String buildTableDdl( + String schema, + String table, + List columns, + List indexes, + List foreignKeys, + boolean useBacktick + ) { + return buildTableDdl(schema, table, columns, indexes, foreignKeys, useBacktick, false); + } + + public static String buildTableDdl( + String schema, + String table, + List columns, + List indexes, + List foreignKeys, + boolean useBacktick, + boolean includeColumnComments + ) { + return buildTableDdl( + schema, + table, + columns, + indexes, + foreignKeys, + Collections.emptyList(), + useBacktick, + includeColumnComments + ); + } + + public static String buildTableDdl( + String schema, + String table, + List columns, + List indexes, + List foreignKeys, + List checkConstraints, + boolean useBacktick, + boolean includeColumnComments + ) { + String tableRef = qualifiedName(schema, table, useBacktick); + List columnLines = new ArrayList<>(); + for (ColumnInfo column : columns) { + StringBuilder line = new StringBuilder(); + line.append(" "); + line.append(quoteIdent(column.getName(), useBacktick)); + line.append(" "); + line.append(columnTypeSql(column)); + if (!column.getIs_nullable()) { + line.append(" NOT NULL"); + } + if (notBlank(column.getColumn_default())) { + line.append(" DEFAULT "); + line.append(column.getColumn_default()); + } + columnLines.add(line.toString()); + } + + List primaryKeys = new ArrayList<>(); + for (ColumnInfo column : columns) { + if (column.getIs_primary_key()) { + primaryKeys.add(quoteIdent(column.getName(), useBacktick)); + } + } + if (!primaryKeys.isEmpty()) { + columnLines.add(" PRIMARY KEY (" + join(primaryKeys, ", ") + ")"); + } + + for (ForeignKeyInfo fk : foreignKeys) { + columnLines.add( + " CONSTRAINT " + quoteIdent(fk.getName(), useBacktick) + + " FOREIGN KEY (" + quoteIdent(fk.getColumn(), useBacktick) + ") " + + "REFERENCES " + quoteIdent(fk.getRef_table(), useBacktick) + "(" + quoteIdent(fk.getRef_column(), useBacktick) + ")" + ); + } + + for (CheckConstraintInfo constraint : checkConstraints) { + if (!notBlank(constraint.getDefinition())) { + continue; + } + String name = notBlank(constraint.getName()) + ? "CONSTRAINT " + quoteIdent(constraint.getName(), useBacktick) + " " + : ""; + columnLines.add(" " + name + constraint.getDefinition()); + } + + StringBuilder ddl = new StringBuilder(); + ddl.append("CREATE TABLE "); + ddl.append(tableRef); + ddl.append(" (\n"); + ddl.append(join(columnLines, ",\n")); + ddl.append("\n);\n"); + + if (includeColumnComments) { + for (ColumnInfo column : columns) { + if (!notBlank(column.getComment())) { + continue; + } + ddl.append("\nCOMMENT ON COLUMN "); + ddl.append(tableRef); + ddl.append("."); + ddl.append(quoteIdent(column.getName(), useBacktick)); + ddl.append(" IS '"); + ddl.append(sqlStringBody(column.getComment())); + ddl.append("';"); + } + } + + for (IndexInfo index : indexes) { + if (index.getIs_primary()) { + continue; + } + String unique = index.getIs_unique() ? "UNIQUE " : ""; + String using = notBlank(index.getIndex_type()) ? " USING " + index.getIndex_type() : ""; + List quotedColumns = new ArrayList<>(); + for (String column : index.getColumns()) { + quotedColumns.add(quoteIdent(column, useBacktick)); + } + String filter = notBlank(index.getFilter()) ? " WHERE " + index.getFilter() : ""; + ddl.append("\nCREATE "); + ddl.append(unique); + ddl.append("INDEX "); + ddl.append(quoteIdent(index.getName(), useBacktick)); + ddl.append(" ON "); + ddl.append(tableRef); + ddl.append(using); + ddl.append(" ("); + ddl.append(join(quotedColumns, ", ")); + ddl.append(")"); + ddl.append(filter); + ddl.append(";"); + if (notBlank(index.getComment())) { + ddl.append("\nCOMMENT ON INDEX "); + if (notBlank(schema)) { + ddl.append(quoteIdent(schema, useBacktick)); + ddl.append("."); + } + ddl.append(quoteIdent(index.getName(), useBacktick)); + ddl.append(" IS '"); + ddl.append(sqlStringBody(index.getComment())); + ddl.append("';"); + } + } + + return ddl.toString(); + } + + private static String quoteIdent(String identifier, boolean useBacktick) { + return useBacktick + ? JdbcIdentifiers.INSTANCE.backtick(identifier) + : JdbcIdentifiers.INSTANCE.doubleQuote(identifier); + } + + private static String qualifiedName(String schema, String name, boolean useBacktick) { + if (!notBlank(schema)) { + return quoteIdent(name, useBacktick); + } + return quoteIdent(schema, useBacktick) + "." + quoteIdent(name, useBacktick); + } + + private static String columnTypeSql(ColumnInfo column) { + String type = column.getData_type(); + String normalized = type.toLowerCase(Locale.ROOT); + if (isCharacterType(normalized) && column.getCharacter_maximum_length() != null) { + return type + "(" + column.getCharacter_maximum_length() + ")"; + } + if (isNumericType(normalized) && column.getNumeric_precision() != null) { + if (column.getNumeric_scale() != null) { + return type + "(" + column.getNumeric_precision() + ", " + column.getNumeric_scale() + ")"; + } + return type + "(" + column.getNumeric_precision() + ")"; + } + return type; + } + + private static boolean isCharacterType(String normalized) { + return "character varying".equals(normalized) + || "varchar".equals(normalized) + || "char".equals(normalized) + || "character".equals(normalized); + } + + private static boolean isNumericType(String normalized) { + return "numeric".equals(normalized) || "decimal".equals(normalized); + } + + private static boolean notBlank(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static String sqlStringBody(String value) { + return value.replace("'", "''"); + } + + private static String join(List values, String separator) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + builder.append(separator); + } + builder.append(values.get(i)); + } + return builder.toString(); + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java b/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java new file mode 100644 index 0000000..96af67a --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/EwkbWktDecoder.java @@ -0,0 +1,671 @@ +package com.dbx.agent; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * Decode PostGIS EWKB / WKB byte streams (and PGgeometry / hex strings) into + * OGC Well-Known Text. Behaves identically to the Rust implementation in + * {@code crates/dbx-core/src/db/postgres.rs::ewkb_to_wkt}, including the + * {@code Z} / {@code M} / {@code ZM} dimension suffixes and {@code EMPTY} + * geometries. Used by the JDBC agent layer so that PostgresLike databases + * such as HighGo / KingBase / Vastbase / openGauss / GaussDB return WKT for + * {@code geometry}/{@code geography} columns instead of opaque hex blobs. + * + *

The decoder is permissive: any unrecognized payload (truncated bytes, + * unsupported geometry kind, non-hex strings, etc.) falls back to a hex + * representation prefixed with {@code 0x} so the cell remains visible. + */ +public final class EwkbWktDecoder { + /** Reflectively-detected PGgeometry classes that expose getValue() returning EWKT text. */ + private static final String[] PGGEOMETRY_CLASS_NAMES = new String[]{ + "org.postgis.PGgeometry", // legacy PostGIS-JDBC + "net.postgis.jdbc.PGgeometry", // modern PostGIS-JDBC (>= 2024 release) + "com.highgo.jdbc.geometry.PGgeometry" + }; + + /** + * Generic {@code org.postgresql.util.PGobject}-shaped classes. Their + * {@code getValue()} returns the textual representation the driver got off + * the wire. For PostGIS columns on HighGo / KingBase / Vastbase that text + * is the EWKB hex (e.g. {@code 0101000020E61000...}) — we still need to run + * it through the EWKB hex parser. + */ + private static final String[] PGOBJECT_CLASS_NAMES = new String[]{ + "org.postgresql.util.PGobject", + "com.highgo.jdbc.util.PGobject", + "org.postgresql.geometric.PGobject" + }; + + private EwkbWktDecoder() { + } + + /** + * Decode a JDBC-returned object from a {@code geometry} / {@code geography} + * column into a textual representation, preferring WKT. + * + *

Accepted inputs: + *

    + *
  • {@code null} — returned as-is.
  • + *
  • {@code byte[]} — interpreted as raw EWKB.
  • + *
  • A {@code PGgeometry}-like object exposing {@code getValue()} — the + * returned string is taken verbatim (already EWKT/WKT).
  • + *
  • {@link String} — if it looks like hex EWKB (with optional + * {@code 0x}/{@code \\x} prefix and a leading endian byte of + * {@code 00}/{@code 01}), it is decoded; otherwise returned as-is.
  • + *
+ * + * @param value raw column value + * @return decoded WKT, or a {@code 0x}-prefixed hex fallback, or {@code null} + */ + public static Object decode(Object value) { + if (value == null) { + return null; + } + if (value instanceof byte[]) { + return decodeBytes((byte[]) value); + } + if (value instanceof String) { + return decodeString((String) value); + } + // PGgeometry / PGobject and friends — pull out their getValue() text + // and run it through the same string path. For PostGIS-JDBC types + // getValue() yields EWKT (passes through unchanged); for the generic + // PGobject driver returns EWKB hex, which decodeString() decodes. + String pgValue = tryReflectiveGetValue(value); + if (pgValue != null) { + return decodeString(pgValue); + } + // Last resort: rely on toString() / underlying value. + return value.toString(); + } + + /** Decode a raw EWKB byte sequence; returns hex {@code 0x..} on failure. */ + static String decodeBytes(byte[] raw) { + if (raw == null) { + return null; + } + String wkt = parseEwkb(raw); + if (wkt != null) { + return wkt; + } + return bytesToHexWithPrefix(raw); + } + + /** + * Decode a string column. If the text matches an EWKB hex layout the bytes + * are decoded; otherwise (already WKT, ST_AsText output, etc.) returned + * unchanged. + */ + static String decodeString(String value) { + if (value == null) { + return null; + } + byte[] bytes = tryParseHex(value); + if (bytes == null) { + return value; + } + String wkt = parseEwkb(bytes); + if (wkt != null) { + return wkt; + } + // looked like hex but couldn't be parsed — preserve hex form. + return ensureHexPrefix(value); + } + + /** + * Try to extract textual value from PGgeometry / PGobject-like objects via + * {@code getValue()}. Returns {@code null} if the class doesn't match any + * known PostGIS / PostgreSQL JDBC type (or the call fails reflectively). + */ + private static String tryReflectiveGetValue(Object value) { + Class cls = value.getClass(); + if (!matchesAnyClassName(cls, PGGEOMETRY_CLASS_NAMES) + && !matchesAnyClassName(cls, PGOBJECT_CLASS_NAMES)) { + return null; + } + try { + Method getValue = cls.getMethod("getValue"); + Object result = getValue.invoke(value); + if (result instanceof String) { + return (String) result; + } + } catch (ReflectiveOperationException ignored) { + // fall through + } + return null; + } + + private static boolean matchesAnyClassName(Class cls, String[] names) { + Class current = cls; + while (current != null) { + for (String name : names) { + if (current.getName().equals(name)) { + return true; + } + for (Class iface : current.getInterfaces()) { + if (iface.getName().equals(name)) { + return true; + } + } + } + current = current.getSuperclass(); + } + return false; + } + + /** @return parsed bytes, or {@code null} if {@code value} is not hex EWKB. */ + static byte[] tryParseHex(String value) { + String s = value; + if (s.length() >= 2 && (s.startsWith("0x") || s.startsWith("0X"))) { + s = s.substring(2); + } else if (s.length() >= 2 && (s.startsWith("\\x") || s.startsWith("\\X"))) { + s = s.substring(2); + } + if (s.length() < 10 || (s.length() & 1) != 0) { + return null; + } + // First byte must be the WKB endian marker (00 = big, 01 = little) + char e0 = s.charAt(0); + char e1 = s.charAt(1); + if (!((e0 == '0') && (e1 == '0' || e1 == '1'))) { + return null; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return null; + } + } + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + private static String ensureHexPrefix(String value) { + if (value.startsWith("0x") || value.startsWith("0X")) { + return value; + } + return "0x" + value; + } + + static String bytesToHexWithPrefix(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2 + 2); + sb.append("0x"); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + // ── EWKB parser ──────────────────────────────────────────────────────── + + private static String parseEwkb(byte[] raw) { + if (raw == null || raw.length == 0) { + return null; + } + Reader reader = new Reader(raw); + Geometry g = parseGeometry(reader); + if (g == null || reader.pos != raw.length) { + return null; + } + return geometryToWkt(g); + } + + private static Geometry parseGeometry(Reader reader) { + Integer endian = reader.readU8(); + if (endian == null) { + return null; + } + boolean little; + if (endian == 0) { + little = false; + } else if (endian == 1) { + little = true; + } else { + return null; + } + Long typeWordBoxed = reader.readU32(little); + if (typeWordBoxed == null) { + return null; + } + long typeWord = typeWordBoxed; + Dims parsed = parseDimensions(typeWord); + long baseType = parsed.baseType; + if (parsed.hasSrid) { + if (reader.readU32(little) == null) { + return null; + } + } + Dimensions dims = parsed.dims; + switch ((int) baseType) { + case 1: { // Point + int len = dims.coordinateLen(); + List coords = new ArrayList<>(len); + for (int i = 0; i < len; i++) { + Double v = reader.readF64(little); + if (v == null) { + return null; + } + coords.add(v); + } + boolean allNan = true; + for (Double v : coords) { + if (!Double.isNaN(v)) { + allNan = false; + break; + } + } + return new Geometry(GeomKind.POINT, dims, allNan ? null : coords, null, null, null, null, null); + } + case 2: { // LineString + List> points = parsePoints(reader, little, dims); + if (points == null) return null; + return new Geometry(GeomKind.LINESTRING, dims, null, points, null, null, null, null); + } + case 3: { // Polygon + Long ringCount = reader.readU32(little); + if (ringCount == null) return null; + List>> rings = new ArrayList<>(); + for (int i = 0; i < ringCount; i++) { + List> ring = parsePoints(reader, little, dims); + if (ring == null) return null; + rings.add(ring); + } + return new Geometry(GeomKind.POLYGON, dims, null, null, rings, null, null, null); + } + case 4: { // MultiPoint + Long count = reader.readU32(little); + if (count == null) return null; + List> mpoints = new ArrayList<>(); + for (int i = 0; i < count; i++) { + List coords = readPointCoords(reader, dims); + if (coords == null) { + // sub-point parse failure (returning null != EMPTY); abort. + return null; + } + boolean allNan = true; + for (Double v : coords) { + if (!Double.isNaN(v)) { + allNan = false; + break; + } + } + mpoints.add(allNan ? null : coords); + } + return new Geometry(GeomKind.MULTIPOINT, dims, null, null, null, mpoints, null, null); + } + case 5: { // MultiLineString + Long count = reader.readU32(little); + if (count == null) return null; + List>> lines = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Geometry sub = parseGeometry(reader); + if (sub == null || sub.kind != GeomKind.LINESTRING) return null; + lines.add(sub.points); + } + return new Geometry(GeomKind.MULTILINESTRING, dims, null, null, lines, null, null, null); + } + case 6: { // MultiPolygon + Long count = reader.readU32(little); + if (count == null) return null; + List>>> polys = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Geometry sub = parseGeometry(reader); + if (sub == null || sub.kind != GeomKind.POLYGON) return null; + polys.add(sub.rings); + } + return new Geometry(GeomKind.MULTIPOLYGON, dims, null, null, null, null, polys, null); + } + case 7: { // GeometryCollection + Long count = reader.readU32(little); + if (count == null) return null; + List children = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Geometry sub = parseGeometry(reader); + if (sub == null) return null; + children.add(sub); + } + return new Geometry(GeomKind.GEOMETRYCOLLECTION, dims, null, null, null, null, null, children); + } + default: + return null; + } + } + + private static List> parsePoints(Reader reader, boolean little, Dimensions dims) { + Long count = reader.readU32(little); + if (count == null) return null; + int dim = dims.coordinateLen(); + List> result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + List coords = new ArrayList<>(dim); + for (int j = 0; j < dim; j++) { + Double v = reader.readF64(little); + if (v == null) return null; + coords.add(v); + } + result.add(coords); + } + return result; + } + + private static List readPointCoords(Reader reader, Dimensions parentDims) { + Integer endian = reader.readU8(); + if (endian == null) return null; + boolean little; + if (endian == 0) { + little = false; + } else if (endian == 1) { + little = true; + } else { + return null; + } + Long typeWord = reader.readU32(little); + if (typeWord == null) return null; + Dims parsed = parseDimensions(typeWord); + if (parsed.baseType != 1 || !parsed.dims.equals(parentDims)) { + return null; + } + if (parsed.hasSrid) { + if (reader.readU32(little) == null) return null; + } + int dim = parsed.dims.coordinateLen(); + List coords = new ArrayList<>(dim); + for (int i = 0; i < dim; i++) { + Double v = reader.readF64(little); + if (v == null) return null; + coords.add(v); + } + return coords; + } + + private static Dims parseDimensions(long typeWord) { + long baseType = typeWord & 0x1FFF_FFFFL; + boolean hasZ = (typeWord & 0x8000_0000L) != 0; + boolean hasM = (typeWord & 0x4000_0000L) != 0; + boolean hasSrid = (typeWord & 0x2000_0000L) != 0; + if (baseType >= 3000) { + hasZ = true; + hasM = true; + baseType -= 3000; + } else if (baseType >= 2000) { + hasM = true; + baseType -= 2000; + } else if (baseType >= 1000) { + hasZ = true; + baseType -= 1000; + } + return new Dims(baseType, new Dimensions(hasZ, hasM), hasSrid); + } + + // ── WKT formatting ───────────────────────────────────────────────────── + + private static String geometryToWkt(Geometry g) { + String suffix = g.dims.suffix(); + switch (g.kind) { + case POINT: + if (g.coords == null) { + return "POINT" + suffix + " EMPTY"; + } + return "POINT" + suffix + "(" + formatCoord(g.coords) + ")"; + case LINESTRING: + if (g.points == null || g.points.isEmpty()) { + return "LINESTRING" + suffix + " EMPTY"; + } + return "LINESTRING" + suffix + "(" + formatCoordSeq(g.points) + ")"; + case POLYGON: + if (g.rings == null || g.rings.isEmpty()) { + return "POLYGON" + suffix + " EMPTY"; + } + return "POLYGON" + suffix + "(" + joinRings(g.rings) + ")"; + case MULTIPOINT: + if (g.multiPoints == null || g.multiPoints.isEmpty()) { + return "MULTIPOINT" + suffix + " EMPTY"; + } + return "MULTIPOINT" + suffix + "(" + joinMultiPoints(g.multiPoints) + ")"; + case MULTILINESTRING: + if (g.rings == null || g.rings.isEmpty()) { + return "MULTILINESTRING" + suffix + " EMPTY"; + } + return "MULTILINESTRING" + suffix + "(" + joinRings(g.rings) + ")"; + case MULTIPOLYGON: + if (g.polygons == null || g.polygons.isEmpty()) { + return "MULTIPOLYGON" + suffix + " EMPTY"; + } + return "MULTIPOLYGON" + suffix + "(" + joinPolygons(g.polygons) + ")"; + case GEOMETRYCOLLECTION: + if (g.children == null || g.children.isEmpty()) { + return "GEOMETRYCOLLECTION" + suffix + " EMPTY"; + } + StringBuilder sb = new StringBuilder("GEOMETRYCOLLECTION").append(suffix).append("("); + for (int i = 0; i < g.children.size(); i++) { + if (i > 0) sb.append(','); + sb.append(geometryToWkt(g.children.get(i))); + } + sb.append(')'); + return sb.toString(); + default: + throw new IllegalStateException("unknown geometry kind: " + g.kind); + } + } + + private static String formatCoord(List coords) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < coords.size(); i++) { + if (i > 0) sb.append(' '); + sb.append(formatDouble(coords.get(i))); + } + return sb.toString(); + } + + private static String formatCoordSeq(List> points) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < points.size(); i++) { + if (i > 0) sb.append(','); + sb.append(formatCoord(points.get(i))); + } + return sb.toString(); + } + + private static String joinRings(List>> rings) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < rings.size(); i++) { + if (i > 0) sb.append(','); + sb.append('(').append(formatCoordSeq(rings.get(i))).append(')'); + } + return sb.toString(); + } + + private static String joinMultiPoints(List> mpoints) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < mpoints.size(); i++) { + if (i > 0) sb.append(','); + List p = mpoints.get(i); + if (p == null) { + sb.append("EMPTY"); + } else { + sb.append('(').append(formatCoord(p)).append(')'); + } + } + return sb.toString(); + } + + private static String joinPolygons(List>>> polys) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < polys.size(); i++) { + if (i > 0) sb.append(','); + sb.append('(').append(joinRings(polys.get(i))).append(')'); + } + return sb.toString(); + } + + /** + * Format a double the same way Rust's {@code f64::Display} does — shortest + * round-trippable decimal, trailing {@code .0} stripped (so {@code 116.0} + * becomes {@code "116"}, not {@code "116.0"}). + */ + static String formatDouble(double value) { + if (Double.isNaN(value)) { + return "NaN"; + } + if (Double.isInfinite(value)) { + return value > 0 ? "inf" : "-inf"; + } + String s = Double.toString(value); + // Strip trailing ".0" for whole-number doubles so output matches Rust. + if (s.endsWith(".0")) { + return s.substring(0, s.length() - 2); + } + return s; + } + + // ── helper structs ───────────────────────────────────────────────────── + + private enum GeomKind { + POINT, LINESTRING, POLYGON, MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION + } + + private static final class Geometry { + final GeomKind kind; + final Dimensions dims; + final List coords; // POINT + final List> points; // LINESTRING + final List>> rings; // POLYGON / MULTILINESTRING (lines == rings shape-wise) + final List> multiPoints; // MULTIPOINT (null entry = EMPTY) + final List>>> polygons; // MULTIPOLYGON + final List children; // GEOMETRYCOLLECTION + + Geometry( + GeomKind kind, + Dimensions dims, + List coords, + List> points, + List>> rings, + List> multiPoints, + List>>> polygons, + List children + ) { + this.kind = kind; + this.dims = dims; + this.coords = coords; + this.points = points; + this.rings = rings; + this.multiPoints = multiPoints; + this.polygons = polygons; + this.children = children; + } + } + + private static final class Dimensions { + final boolean hasZ; + final boolean hasM; + + Dimensions(boolean hasZ, boolean hasM) { + this.hasZ = hasZ; + this.hasM = hasM; + } + + String suffix() { + if (hasZ && hasM) return " ZM"; + if (hasZ) return " Z"; + if (hasM) return " M"; + return ""; + } + + int coordinateLen() { + return 2 + (hasZ ? 1 : 0) + (hasM ? 1 : 0); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Dimensions)) return false; + Dimensions d = (Dimensions) o; + return hasZ == d.hasZ && hasM == d.hasM; + } + + @Override + public int hashCode() { + return (hasZ ? 1 : 0) * 31 + (hasM ? 1 : 0); + } + } + + private static final class Dims { + final long baseType; + final Dimensions dims; + final boolean hasSrid; + + Dims(long baseType, Dimensions dims, boolean hasSrid) { + this.baseType = baseType; + this.dims = dims; + this.hasSrid = hasSrid; + } + } + + private static final class Reader { + final byte[] raw; + int pos; + + Reader(byte[] raw) { + this.raw = raw; + } + + Integer readU8() { + if (pos >= raw.length) return null; + int v = raw[pos] & 0xFF; + pos += 1; + return v; + } + + Long readU32(boolean little) { + if (pos + 4 > raw.length) return null; + long v; + if (little) { + v = ((long) (raw[pos] & 0xFF)) + | ((long) (raw[pos + 1] & 0xFF) << 8) + | ((long) (raw[pos + 2] & 0xFF) << 16) + | ((long) (raw[pos + 3] & 0xFF) << 24); + } else { + v = ((long) (raw[pos] & 0xFF) << 24) + | ((long) (raw[pos + 1] & 0xFF) << 16) + | ((long) (raw[pos + 2] & 0xFF) << 8) + | ((long) (raw[pos + 3] & 0xFF)); + } + pos += 4; + return v & 0xFFFF_FFFFL; + } + + Double readF64(boolean little) { + if (pos + 8 > raw.length) return null; + long bits; + if (little) { + bits = ((long) (raw[pos] & 0xFF)) + | ((long) (raw[pos + 1] & 0xFF) << 8) + | ((long) (raw[pos + 2] & 0xFF) << 16) + | ((long) (raw[pos + 3] & 0xFF) << 24) + | ((long) (raw[pos + 4] & 0xFF) << 32) + | ((long) (raw[pos + 5] & 0xFF) << 40) + | ((long) (raw[pos + 6] & 0xFF) << 48) + | ((long) (raw[pos + 7] & 0xFF) << 56); + } else { + bits = ((long) (raw[pos] & 0xFF) << 56) + | ((long) (raw[pos + 1] & 0xFF) << 48) + | ((long) (raw[pos + 2] & 0xFF) << 40) + | ((long) (raw[pos + 3] & 0xFF) << 32) + | ((long) (raw[pos + 4] & 0xFF) << 24) + | ((long) (raw[pos + 5] & 0xFF) << 16) + | ((long) (raw[pos + 6] & 0xFF) << 8) + | ((long) (raw[pos + 7] & 0xFF)); + } + pos += 8; + return Double.longBitsToDouble(bits); + } + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ExecuteQueryOptions.java b/agents/common/src/main/java/com/dbx/agent/ExecuteQueryOptions.java new file mode 100644 index 0000000..ae33923 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ExecuteQueryOptions.java @@ -0,0 +1,67 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class ExecuteQueryOptions { + private int maxRows; + private Integer fetchSize; + private int timeoutSecs; + + public ExecuteQueryOptions() { + this(JdbcExecutor.DEFAULT_MAX_ROWS, null, 0); + } + + public ExecuteQueryOptions(int maxRows, Integer fetchSize) { + this(maxRows, fetchSize, 0); + } + + public ExecuteQueryOptions(int maxRows, Integer fetchSize, int timeoutSecs) { + this.maxRows = maxRows; + this.fetchSize = fetchSize; + this.timeoutSecs = timeoutSecs; + } + + public int getMaxRows() { + return maxRows; + } + + public Integer getFetchSize() { + return fetchSize; + } + + public int getTimeoutSecs() { + return timeoutSecs; + } + + public void setMaxRows(int maxRows) { + this.maxRows = maxRows; + } + + public void setFetchSize(Integer fetchSize) { + this.fetchSize = fetchSize; + } + + public void setTimeoutSecs(int timeoutSecs) { + this.timeoutSecs = timeoutSecs; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ExecuteQueryOptions)) return false; + ExecuteQueryOptions that = (ExecuteQueryOptions) other; + return maxRows == that.maxRows + && timeoutSecs == that.timeoutSecs + && Objects.equals(fetchSize, that.fetchSize); + } + + @Override + public int hashCode() { + return Objects.hash(maxRows, fetchSize, timeoutSecs); + } + + @Override + public String toString() { + return "ExecuteQueryOptions(maxRows=" + maxRows + ", fetchSize=" + fetchSize + ", timeoutSecs=" + timeoutSecs + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ExecuteQueryParams.java b/agents/common/src/main/java/com/dbx/agent/ExecuteQueryParams.java new file mode 100644 index 0000000..0f96201 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ExecuteQueryParams.java @@ -0,0 +1,78 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class ExecuteQueryParams { + private String sql; + private String schema; + private Integer maxRows; + private Integer fetchSize; + + public ExecuteQueryParams() { + this("", null, null, null); + } + + public ExecuteQueryParams(String sql, String schema, Integer maxRows, Integer fetchSize) { + this.sql = sql; + this.schema = schema; + this.maxRows = maxRows; + this.fetchSize = fetchSize; + } + + public String getSql() { + return sql; + } + + public String getSchema() { + return schema; + } + + public Integer getMaxRows() { + return maxRows; + } + + public Integer getFetchSize() { + return fetchSize; + } + + public void setSql(String sql) { + this.sql = sql; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public void setMaxRows(Integer maxRows) { + this.maxRows = maxRows; + } + + public void setFetchSize(Integer fetchSize) { + this.fetchSize = fetchSize; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ExecuteQueryParams)) return false; + ExecuteQueryParams that = (ExecuteQueryParams) other; + return Objects.equals(sql, that.sql) + && Objects.equals(schema, that.schema) + && Objects.equals(maxRows, that.maxRows) + && Objects.equals(fetchSize, that.fetchSize); + } + + @Override + public int hashCode() { + return Objects.hash(sql, schema, maxRows, fetchSize); + } + + @Override + public String toString() { + return "ExecuteQueryParams(sql=" + sql + + ", schema=" + schema + + ", maxRows=" + maxRows + + ", fetchSize=" + fetchSize + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ForeignKeyInfo.java b/agents/common/src/main/java/com/dbx/agent/ForeignKeyInfo.java new file mode 100644 index 0000000..de018df --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ForeignKeyInfo.java @@ -0,0 +1,78 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class ForeignKeyInfo { + private String name; + private String column; + private String ref_table; + private String ref_column; + + public ForeignKeyInfo() { + this("", "", "", ""); + } + + public ForeignKeyInfo(String name, String column, String ref_table, String ref_column) { + this.name = name; + this.column = column; + this.ref_table = ref_table; + this.ref_column = ref_column; + } + + public String getName() { + return name; + } + + public String getColumn() { + return column; + } + + public String getRef_table() { + return ref_table; + } + + public String getRef_column() { + return ref_column; + } + + public void setName(String name) { + this.name = name; + } + + public void setColumn(String column) { + this.column = column; + } + + public void setRef_table(String ref_table) { + this.ref_table = ref_table; + } + + public void setRef_column(String ref_column) { + this.ref_column = ref_column; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ForeignKeyInfo)) return false; + ForeignKeyInfo that = (ForeignKeyInfo) other; + return Objects.equals(name, that.name) + && Objects.equals(column, that.column) + && Objects.equals(ref_table, that.ref_table) + && Objects.equals(ref_column, that.ref_column); + } + + @Override + public int hashCode() { + return Objects.hash(name, column, ref_table, ref_column); + } + + @Override + public String toString() { + return "ForeignKeyInfo(name=" + name + + ", column=" + column + + ", ref_table=" + ref_table + + ", ref_column=" + ref_column + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/IndexInfo.java b/agents/common/src/main/java/com/dbx/agent/IndexInfo.java new file mode 100644 index 0000000..ea38747 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/IndexInfo.java @@ -0,0 +1,141 @@ +package com.dbx.agent; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class IndexInfo { + private String name; + private List columns; + private boolean is_unique; + private boolean is_primary; + private String filter; + private String index_type; + private List included_columns; + private String comment; + + public IndexInfo() { + this("", Collections.emptyList(), false, false); + } + + public IndexInfo(String name, List columns, boolean is_unique, boolean is_primary) { + this(name, columns, is_unique, is_primary, null, null, null, null); + } + + public IndexInfo( + String name, + List columns, + boolean is_unique, + boolean is_primary, + String filter, + String index_type, + List included_columns, + String comment + ) { + this.name = name; + this.columns = columns == null ? Collections.emptyList() : columns; + this.is_unique = is_unique; + this.is_primary = is_primary; + this.filter = filter; + this.index_type = index_type; + this.included_columns = included_columns; + this.comment = comment; + } + + public String getName() { + return name; + } + + public List getColumns() { + return columns; + } + + public boolean getIs_unique() { + return is_unique; + } + + public boolean getIs_primary() { + return is_primary; + } + + public String getFilter() { + return filter; + } + + public String getIndex_type() { + return index_type; + } + + public List getIncluded_columns() { + return included_columns; + } + + public String getComment() { + return comment; + } + + public void setName(String name) { + this.name = name; + } + + public void setColumns(List columns) { + this.columns = columns; + } + + public void setIs_unique(boolean is_unique) { + this.is_unique = is_unique; + } + + public void setIs_primary(boolean is_primary) { + this.is_primary = is_primary; + } + + public void setFilter(String filter) { + this.filter = filter; + } + + public void setIndex_type(String index_type) { + this.index_type = index_type; + } + + public void setIncluded_columns(List included_columns) { + this.included_columns = included_columns; + } + + public void setComment(String comment) { + this.comment = comment; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof IndexInfo)) return false; + IndexInfo that = (IndexInfo) other; + return is_unique == that.is_unique + && is_primary == that.is_primary + && Objects.equals(name, that.name) + && Objects.equals(columns, that.columns) + && Objects.equals(filter, that.filter) + && Objects.equals(index_type, that.index_type) + && Objects.equals(included_columns, that.included_columns) + && Objects.equals(comment, that.comment); + } + + @Override + public int hashCode() { + return Objects.hash(name, columns, is_unique, is_primary, filter, index_type, included_columns, comment); + } + + @Override + public String toString() { + return "IndexInfo(name=" + name + + ", columns=" + columns + + ", is_unique=" + is_unique + + ", is_primary=" + is_primary + + ", filter=" + filter + + ", index_type=" + index_type + + ", included_columns=" + included_columns + + ", comment=" + comment + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcAgentProfile.java b/agents/common/src/main/java/com/dbx/agent/JdbcAgentProfile.java new file mode 100644 index 0000000..433f817 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/JdbcAgentProfile.java @@ -0,0 +1,223 @@ +package com.dbx.agent; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +public class JdbcAgentProfile { + private final String driverClass; + private final String urlTemplate; + private final int defaultPort; + private final boolean skipExecutionContext; + private final Set excludedSchemas; + private final List tableTypes; + private final String identifierQuote; + private final String schemaSwitchPrefix; + private final boolean catalogFallbackEnabled; + private final boolean nativeTableDdlSupported; + private final boolean objectSourceSupported; + private final boolean triggersSupported; + + public JdbcAgentProfile(String driverClass, String urlTemplate) { + this(driverClass, urlTemplate, 0); + } + + public JdbcAgentProfile(String driverClass, String urlTemplate, int defaultPort) { + this(driverClass, urlTemplate, defaultPort, false); + } + + public JdbcAgentProfile(String driverClass, String urlTemplate, int defaultPort, boolean skipExecutionContext) { + this( + driverClass, + urlTemplate, + defaultPort, + skipExecutionContext, + Collections.emptySet(), + Arrays.asList("TABLE", "VIEW", "BASE TABLE", "MATERIALIZED VIEW", "SYSTEM TABLE", "SYSTEM VIEW") + ); + } + + public JdbcAgentProfile( + String driverClass, + String urlTemplate, + int defaultPort, + boolean skipExecutionContext, + Set excludedSchemas, + List tableTypes + ) { + this( + driverClass, + urlTemplate, + defaultPort, + skipExecutionContext, + excludedSchemas, + tableTypes, + "\"", + "SET SCHEMA", + true, + false, + false, + false + ); + } + + public JdbcAgentProfile( + String driverClass, + String urlTemplate, + int defaultPort, + boolean skipExecutionContext, + Set excludedSchemas, + List tableTypes, + String identifierQuote, + String schemaSwitchPrefix, + boolean catalogFallbackEnabled, + boolean nativeTableDdlSupported, + boolean objectSourceSupported, + boolean triggersSupported + ) { + this.driverClass = driverClass; + this.urlTemplate = urlTemplate; + this.defaultPort = defaultPort; + this.skipExecutionContext = skipExecutionContext; + this.excludedSchemas = excludedSchemas; + this.tableTypes = tableTypes; + this.identifierQuote = identifierQuote; + this.schemaSwitchPrefix = schemaSwitchPrefix; + this.catalogFallbackEnabled = catalogFallbackEnabled; + this.nativeTableDdlSupported = nativeTableDdlSupported; + this.objectSourceSupported = objectSourceSupported; + this.triggersSupported = triggersSupported; + } + + public String getDriverClass() { + return driverClass; + } + + public String getUrlTemplate() { + return urlTemplate; + } + + public int getDefaultPort() { + return defaultPort; + } + + public boolean getSkipExecutionContext() { + return skipExecutionContext; + } + + public Set getExcludedSchemas() { + return excludedSchemas; + } + + public List getTableTypes() { + return tableTypes; + } + + public String getIdentifierQuote() { + return identifierQuote; + } + + public String getSchemaSwitchPrefix() { + return schemaSwitchPrefix; + } + + public boolean getCatalogFallbackEnabled() { + return catalogFallbackEnabled; + } + + public boolean getNativeTableDdlSupported() { + return nativeTableDdlSupported; + } + + public boolean getObjectSourceSupported() { + return objectSourceSupported; + } + + public boolean getTriggersSupported() { + return triggersSupported; + } + + public String buildUrl(ConnectParams params) { + if (!params.getConnection_string().trim().isEmpty()) { + return params.getConnection_string(); + } + int port = params.getPort() > 0 ? params.getPort() : defaultPort; + String base = urlTemplate + .replace("{host}", params.getHost()) + .replace("{port}", Integer.toString(port)) + .replace("{database}", params.getDatabase()); + return appendUrlParams(base, params.getUrl_params()); + } + + public String quoteIdentifier(String identifier) { + return identifierQuote + identifier.replace(identifierQuote, identifierQuote + identifierQuote) + identifierQuote; + } + + public String schemaSwitchSql(String schema) { + return schemaSwitchSql(schema, identifierQuote); + } + + public String schemaSwitchSql(String schema, String quote) { + if (skipExecutionContext) { + return ""; + } + return schemaSwitchPrefix + " " + quote + schema.replace(quote, quote + quote) + quote; + } + + private static String appendUrlParams(String url, String urlParams) { + String params = trimUrlParams(urlParams); + if (params.isEmpty()) { + return url; + } + if (usesColonProperties(url) && !params.endsWith(";")) { + params = params + ";"; + } + String separator = urlParamSeparator(url); + return url + separator + params; + } + + private static String trimUrlParams(String urlParams) { + String value = urlParams == null ? "" : urlParams.trim(); + while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";") || value.startsWith(":") || value.startsWith(",")) { + value = value.substring(1); + } + return value; + } + + private static String urlParamSeparator(String url) { + if (startsWithIgnoreCase(url, "jdbc:sqlserver:") || startsWithIgnoreCase(url, "jdbc:exa:")) { + return url.endsWith(";") ? "" : ";"; + } + if (startsWithIgnoreCase(url, "jdbc:teradata:")) { + return url.endsWith(",") ? "" : ","; + } + if (usesColonProperties(url)) { + if (url.endsWith(":") || url.endsWith(";")) { + return ""; + } + return hasColonProperties(url) ? ";" : ":"; + } + return url.contains("?") ? "&" : "?"; + } + + private static boolean usesColonProperties(String url) { + return startsWithIgnoreCase(url, "jdbc:db2:") || startsWithIgnoreCase(url, "jdbc:informix-sqli:"); + } + + private static boolean hasColonProperties(String url) { + int schemeEnd = url.indexOf("://"); + if (schemeEnd < 0) { + return false; + } + int pathStart = url.indexOf('/', schemeEnd + 3); + if (pathStart < 0) { + return false; + } + return url.indexOf(':', pathStart + 1) >= 0; + } + + private static boolean startsWithIgnoreCase(String value, String prefix) { + return value.regionMatches(true, 0, prefix, 0, prefix.length()); + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java b/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java new file mode 100644 index 0000000..cbd77dc --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java @@ -0,0 +1,706 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.SQLXML; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +public final class JdbcExecutor { + public static final JdbcExecutor INSTANCE = new JdbcExecutor(); + public static final int DEFAULT_MAX_ROWS = 10000; + public static final long QUERY_SESSION_IDLE_TIMEOUT_MILLIS = 10 * 60 * 1000L; + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap tableReadSessions = new ConcurrentHashMap<>(); + + private JdbcExecutor() { + } + + public QueryResult execute(Connection conn, String sql, String schema, Function setSchemaSql) { + return execute(conn, sql, schema, setSchemaSql, DEFAULT_MAX_ROWS, null, this::defaultResultValue); + } + + public QueryResult execute( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + int maxRows, + Integer fetchSize + ) { + return execute(conn, sql, schema, setSchemaSql, maxRows, fetchSize, this::defaultResultValue); + } + + public QueryResult execute( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + int maxRows, + Integer fetchSize, + ResultValueReader valueReader + ) { + return execute(conn, sql, schema, setSchemaSql, maxRows, fetchSize, 0, valueReader); + } + + public QueryResult execute( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + int maxRows, + Integer fetchSize, + int timeoutSecs, + ResultValueReader valueReader + ) { + return unchecked(() -> { + String trimmedSql = trimSql(sql); + long start = System.currentTimeMillis(); + + applySchema(conn, schema, setSchemaSql); + + try (Statement stmt = conn.createStatement()) { + int effectiveMaxRows = Math.max(maxRows, 1); + stmt.setMaxRows(effectiveMaxRows + 1); + applyQueryTimeout(stmt, timeoutSecs); + if (fetchSize != null && fetchSize > 0) { + stmt.setFetchSize(fetchSize); + } + // SQL dumps often contain BEGIN/COMMIT/ROLLBACK as executable statements. + // Do not translate them to Connection.commit(), which requires autoCommit=false. + boolean hasResultSet = stmt.execute(trimmedSql); + long elapsed = System.currentTimeMillis() - start; + if (hasResultSet) { + try (ResultSet rs = stmt.getResultSet()) { + return readResultSet(rs, elapsed, effectiveMaxRows, valueReader); + } + } + + int updateCount = stmt.getUpdateCount(); + return new QueryResult( + Collections.emptyList(), + Collections.emptyList(), + updateCount >= 0 ? updateCount : 0, + elapsed, + false + ); + } + }); + } + + public QueryResult readResultSet(ResultSet rs, long executionTimeMs) { + return readResultSet(rs, executionTimeMs, DEFAULT_MAX_ROWS, this::defaultResultValue); + } + + public QueryResult readResultSet( + ResultSet rs, + long executionTimeMs, + int maxRows, + ResultValueReader valueReader + ) { + return unchecked(() -> { + ResultSetMetaData meta = rs.getMetaData(); + int colCount = meta.getColumnCount(); + List columns = new ArrayList<>(colCount); + List columnTypes = new ArrayList<>(colCount); + // Cache JDBC column metadata once; some drivers resolve it lazily, + // and row reading is the hot path. + int[] sqlTypeByIndex = new int[colCount]; + String[] typeNameByIndex = new String[colCount]; + for (int i = 1; i <= colCount; i++) { + columns.add(meta.getColumnLabel(i)); + sqlTypeByIndex[i - 1] = safeColumnSqlType(meta, i); + String typeName = safeColumnTypeName(meta, i); + columnTypes.add(typeName); + typeNameByIndex[i - 1] = typeName; + } + + List> rows = new ArrayList<>(initialRowCapacity(maxRows)); + boolean truncated = false; + while (rs.next()) { + if (rows.size() >= maxRows) { + truncated = true; + break; + } + rows.add(rowValues(rs, valueReader, sqlTypeByIndex, typeNameByIndex)); + } + + return new QueryResult(columns, columnTypes, rows, 0L, executionTimeMs, truncated); + }); + } + + public QueryPageResult executePage( + Connection conn, + String sql, + String schema, + Function setSchemaSql + ) { + return executePage(conn, sql, schema, setSchemaSql, new QueryPageOptions(), this::defaultResultValue); + } + + public QueryPageResult executePage( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + QueryPageOptions options + ) { + return executePage(conn, sql, schema, setSchemaSql, options, this::defaultResultValue); + } + + public QueryPageResult executePage( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + QueryPageOptions options, + ResultValueReader valueReader + ) { + return executePage(conn, sql, schema, setSchemaSql, options, valueReader, sessions); + } + + public QueryPageResult startTableRead( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + QueryPageOptions options, + ResultValueReader valueReader + ) { + return executePage(conn, sql, schema, setSchemaSql, options, valueReader, tableReadSessions); + } + + private QueryPageResult executePage( + Connection conn, + String sql, + String schema, + Function setSchemaSql, + QueryPageOptions options, + ResultValueReader valueReader, + ConcurrentHashMap targetSessions + ) { + return unchecked(() -> { + expireIdleSessions(targetSessions, System.currentTimeMillis(), QUERY_SESSION_IDLE_TIMEOUT_MILLIS); + String trimmedSql = trimSql(sql); + long start = System.currentTimeMillis(); + + applySchema(conn, schema, setSchemaSql); + + Statement stmt = conn.createStatement(); + try { + applyQueryTimeout(stmt, options.getTimeoutSecs()); + if (options.getFetchSize() != null && options.getFetchSize() > 0) { + stmt.setFetchSize(options.getFetchSize()); + } + // Keep script transaction-control statements in the SQL stream. + // JDBC transaction APIs are reserved for executeTransaction. + boolean hasResultSet = stmt.execute(trimmedSql); + long elapsed = System.currentTimeMillis() - start; + if (!hasResultSet) { + int updateCount = stmt.getUpdateCount(); + stmt.close(); + return new QueryPageResult( + Collections.emptyList(), + Collections.emptyList(), + updateCount >= 0 ? updateCount : 0, + elapsed + ); + } + + ResultSet rs = stmt.getResultSet(); + ResultSetMetaData meta = rs.getMetaData(); + String sessionId = UUID.randomUUID().toString(); + int colCount = meta.getColumnCount(); + List columns = new ArrayList<>(colCount); + List columnTypes = new ArrayList<>(colCount); + // Cache JDBC column metadata once; fetching a page should only read row values. + int[] sqlTypeByIndex = new int[colCount]; + String[] typeNameByIndex = new String[colCount]; + for (int i = 1; i <= colCount; i++) { + columns.add(meta.getColumnLabel(i)); + sqlTypeByIndex[i - 1] = safeColumnSqlType(meta, i); + String typeName = safeColumnTypeName(meta, i); + columnTypes.add(typeName); + typeNameByIndex[i - 1] = typeName; + } + QuerySession session = new QuerySession( + sessionId, + stmt, + rs, + columns, + columnTypes, + sqlTypeByIndex, + typeNameByIndex, + Math.max(options.getMaxRows(), 1), + valueReader + ); + targetSessions.put(sessionId, session); + return readSessionPage(targetSessions, session, options.getPageSize(), elapsed); + } catch (Exception e) { + try { + stmt.close(); + } catch (Exception ignored) { + } + throw e; + } + }); + } + + public QueryPageResult fetchPage(String sessionId, int pageSize) { + expireIdleQuerySessions(); + return fetchSessionPage(sessions, sessionId, pageSize, "Query session not found"); + } + + public boolean closeQuerySession(String sessionId) { + return closeSession(sessions, sessionId); + } + + public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) { + expireIdleTableReadSessions(); + return fetchSessionPage(tableReadSessions, sessionId, pageSize, "Table read session not found"); + } + + public boolean closeTableReadSession(String sessionId) { + return closeSession(tableReadSessions, sessionId); + } + + public void closeAllQuerySessions() { + closeAllSessions(sessions); + } + + public void closeAllTableReadSessions() { + closeAllSessions(tableReadSessions); + } + + public int expireIdleQuerySessions() { + return expireIdleQuerySessions(System.currentTimeMillis(), QUERY_SESSION_IDLE_TIMEOUT_MILLIS); + } + + public int expireIdleQuerySessions(long nowMillis, long idleTimeoutMillis) { + return expireIdleSessions(sessions, nowMillis, idleTimeoutMillis); + } + + public int expireIdleTableReadSessions() { + return expireIdleTableReadSessions(System.currentTimeMillis(), QUERY_SESSION_IDLE_TIMEOUT_MILLIS); + } + + public int expireIdleTableReadSessions(long nowMillis, long idleTimeoutMillis) { + return expireIdleSessions(tableReadSessions, nowMillis, idleTimeoutMillis); + } + + private int expireIdleSessions( + ConcurrentHashMap targetSessions, + long nowMillis, + long idleTimeoutMillis + ) { + if (idleTimeoutMillis < 0) { + return 0; + } + int closed = 0; + List snapshot = new ArrayList<>(targetSessions.values()); + for (QuerySession session : snapshot) { + boolean expired; + synchronized (session) { + expired = nowMillis - session.lastAccessedAtMillis >= idleTimeoutMillis; + } + if (expired && closeSession(targetSessions, session.id)) { + closed += 1; + } + } + return closed; + } + + public Object defaultResultValue(ResultSet rs, int index, int sqlType) throws SQLException { + Object value; + switch (sqlType) { + case Types.BIGINT: + value = rs.getLong(index); + break; + case Types.INTEGER: + case Types.SMALLINT: + case Types.TINYINT: + value = rs.getInt(index); + break; + case Types.FLOAT: + case Types.REAL: + value = rs.getFloat(index); + break; + case Types.DOUBLE: + value = rs.getDouble(index); + break; + case Types.DECIMAL: + case Types.NUMERIC: + value = rs.getBigDecimal(index); + break; + case Types.BOOLEAN: + case Types.BIT: + value = rs.getBoolean(index); + break; + case Types.CHAR: + case Types.VARCHAR: + case Types.LONGVARCHAR: + case Types.NCHAR: + case Types.NVARCHAR: + case Types.LONGNVARCHAR: + case Types.CLOB: + case Types.NCLOB: + value = rs.getString(index); + break; + case Types.BINARY: + case Types.VARBINARY: + case Types.LONGVARBINARY: + case Types.BLOB: + value = bytesToHex(rs.getBytes(index)); + break; + case Types.SQLXML: + value = sqlXmlToString(rs.getSQLXML(index)); + break; + default: + value = normalizeResultValue(rs.getObject(index)); + break; + } + return rs.wasNull() ? null : value; + } + + public static Object stringResultValue(ResultSet rs, int index, int sqlType) throws SQLException { + Object value; + switch (sqlType) { + case Types.BINARY: + case Types.VARBINARY: + case Types.LONGVARBINARY: + case Types.BLOB: + value = bytesToHex(rs.getBytes(index)); + break; + case Types.SQLXML: + value = sqlXmlToString(rs.getSQLXML(index)); + break; + default: + value = rs.getString(index); + break; + } + return rs.wasNull() ? null : value; + } + + public static Object normalizeResultValue(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof Clob) { + Clob clob = (Clob) value; + return clob.getSubString(1, Math.toIntExact(clob.length())); + } + if (value instanceof Blob) { + Blob blob = (Blob) value; + return bytesToHex(blob.getBytes(1, Math.toIntExact(blob.length()))); + } + if (value instanceof SQLXML) { + SQLXML sqlxml = (SQLXML) value; + return sqlxml.getString(); + } + if (value instanceof byte[]) { + byte[] bytes = (byte[]) value; + return bytesToHex(bytes); + } + return value instanceof Number || value instanceof Boolean ? value : value.toString(); + } + + public static String bytesToHex(byte[] bytes) { + if (bytes == null) { + return null; + } + StringBuilder result = new StringBuilder(bytes.length * 2 + 2); + result.append("0x"); + for (byte b : bytes) { + result.append(Character.forDigit((b >> 4) & 0xF, 16)); + result.append(Character.forDigit(b & 0xF, 16)); + } + return result.toString(); + } + + private static String sqlXmlToString(SQLXML value) throws SQLException { + return value == null ? null : value.getString(); + } + + private QueryPageResult fetchSessionPage( + ConcurrentHashMap targetSessions, + String sessionId, + int pageSize, + String missingMessage + ) { + QuerySession session = targetSessions.get(sessionId); + if (session == null) { + throw new IllegalArgumentException(missingMessage); + } + synchronized (session) { + return readSessionPage(targetSessions, session, pageSize, 0L); + } + } + + private QueryPageResult readSessionPage( + ConcurrentHashMap targetSessions, + QuerySession session, + int pageSize, + long executionTimeMs + ) { + return unchecked(() -> { + session.lastAccessedAtMillis = System.currentTimeMillis(); + int effectivePageSize = Math.max(pageSize, 1); + List> rows = new ArrayList<>(initialRowCapacity(effectivePageSize)); + + if (session.pendingRow != null) { + rows.add(session.pendingRow); + session.pendingRow = null; + } + + while (rows.size() < effectivePageSize && session.rowsRead < session.maxRows) { + if (!session.resultSet.next()) { + closeSession(targetSessions, session.id); + return new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, false, null, false); + } + rows.add(rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex)); + session.rowsRead += 1; + } + + if (session.rowsRead >= session.maxRows) { + boolean truncated = session.resultSet.next(); + closeSession(targetSessions, session.id); + return new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, truncated, null, false); + } + + boolean hasMore = session.resultSet.next(); + if (!hasMore) { + closeSession(targetSessions, session.id); + return new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, false, null, false); + } + + session.pendingRow = rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex); + session.rowsRead += 1; + return new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, false, session.id, true); + }); + } + + private void closeAllSessions(ConcurrentHashMap targetSessions) { + List ids = new ArrayList<>(targetSessions.keySet()); + for (String id : ids) { + closeSession(targetSessions, id); + } + } + + private boolean closeSession(ConcurrentHashMap targetSessions, String sessionId) { + QuerySession session = targetSessions.remove(sessionId); + if (session == null) { + return false; + } + synchronized (session) { + try { + session.resultSet.close(); + } catch (Exception ignored) { + } + try { + session.statement.close(); + } catch (Exception ignored) { + } + } + return true; + } + + static String safeColumnTypeName(ResultSetMetaData meta, int columnIndex) { + try { + String name = meta.getColumnTypeName(columnIndex); + return name == null ? "" : name; + } catch (SQLException ignored) { + return ""; + } + } + + static int safeColumnSqlType(ResultSetMetaData meta, int columnIndex) { + try { + return meta.getColumnType(columnIndex); + } catch (SQLException ignored) { + return Types.OTHER; + } + } + + private List rowValues( + ResultSet rs, + ResultValueReader valueReader, + int[] sqlTypeByIndex, + String[] typeNameByIndex + ) throws SQLException { + int colCount = sqlTypeByIndex.length; + List row = new ArrayList<>(colCount); + for (int i = 1; i <= colCount; i++) { + int sqlType = sqlTypeByIndex[i - 1]; + Object value; + if (valueReader instanceof ColumnAwareResultValueReader) { + String typeName = typeNameByIndex != null && i - 1 < typeNameByIndex.length + ? typeNameByIndex[i - 1] + : ""; + value = ((ColumnAwareResultValueReader) valueReader).read(rs, i, sqlType, typeName); + } else { + value = valueReader.read(rs, i, sqlType); + } + row.add(value); + } + return row; + } + + private static int initialRowCapacity(int requestedRows) { + if (requestedRows <= 0) { + return 0; + } + return Math.min(requestedRows, 1024); + } + + private void applySchema(Connection conn, String schema, Function setSchemaSql) throws SQLException { + try { + JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql); + } catch (SQLException e) { + throw e; + } catch (Exception e) { + throw new SQLException(e); + } + } + + private static void applyQueryTimeout(Statement stmt, int timeoutSecs) throws SQLException { + if (timeoutSecs > 0) { + stmt.setQueryTimeout(timeoutSecs); + } + } + + private QueryResult emptyQueryResult(long start) { + return new QueryResult( + Collections.emptyList(), + Collections.emptyList(), + 0L, + System.currentTimeMillis() - start + ); + } + + private QueryPageResult emptyQueryPageResult(long start) { + return new QueryPageResult( + Collections.emptyList(), + Collections.emptyList(), + 0L, + System.currentTimeMillis() - start + ); + } + + static String trimSql(String sql) { + String trimmed = stripTrailingSlashDelimiter(sql.trim()); + if (isPlSqlBlock(trimmed)) { + return trimmed; + } + while (trimmed.endsWith(";")) { + trimmed = trimmed.substring(0, trimmed.length() - 1).trim(); + } + return trimmed; + } + + private static String stripTrailingSlashDelimiter(String sql) { + String trimmed = sql.trim(); + if (!trimmed.endsWith("/")) { + return trimmed; + } + int slashStart = trimmed.length() - 1; + int lineStart = trimmed.lastIndexOf('\n', slashStart - 1) + 1; + if (!trimmed.substring(lineStart, slashStart).trim().isEmpty()) { + return trimmed; + } + String beforeSlash = trimmed.substring(0, lineStart).trim(); + return isPlSqlBlock(beforeSlash) ? beforeSlash : trimmed; + } + + private static boolean isPlSqlBlock(String sql) { + String upperSql = sql.toUpperCase(Locale.ROOT).trim(); + if (!upperSql.startsWith("DECLARE") && !upperSql.startsWith("BEGIN")) { + return false; + } + return upperSql.matches("(?s).*\\bEND\\s+(?!IF\\b|LOOP\\b|CASE\\b)[A-Z0-9_$#]+\\s*;\\s*$") + || upperSql.matches("(?s).*\\bEND\\s*;\\s*$"); + } + + private static T unchecked(ThrowingSupplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @FunctionalInterface + public interface ResultValueReader { + Object read(ResultSet rs, int index, int sqlType) throws SQLException; + } + + /** + * Optional extension of {@link ResultValueReader} that exposes the JDBC + * {@code getColumnTypeName} alongside the SQL type code, allowing per-driver + * agents to convert vendor-specific column types (e.g. PostGIS + * {@code geometry}) without re-querying the metadata. + */ + public interface ColumnAwareResultValueReader extends ResultValueReader { + Object read(ResultSet rs, int index, int sqlType, String columnTypeName) throws SQLException; + + @Override + default Object read(ResultSet rs, int index, int sqlType) throws SQLException { + return read(rs, index, sqlType, safeColumnTypeName(rs.getMetaData(), index)); + } + } + + private interface ThrowingSupplier { + T get() throws Exception; + } + + private static final class QuerySession { + private final String id; + private final Statement statement; + private final ResultSet resultSet; + private final List columns; + private final List columnTypes; + private final int[] sqlTypeByIndex; + private final String[] typeNameByIndex; + private final int maxRows; + private final ResultValueReader valueReader; + private int rowsRead; + private List pendingRow; + private long lastAccessedAtMillis; + + private QuerySession( + String id, + Statement statement, + ResultSet resultSet, + List columns, + List columnTypes, + int[] sqlTypeByIndex, + String[] typeNameByIndex, + int maxRows, + ResultValueReader valueReader + ) { + this.id = id; + this.statement = statement; + this.resultSet = resultSet; + this.columns = columns; + this.columnTypes = columnTypes; + this.sqlTypeByIndex = sqlTypeByIndex; + this.typeNameByIndex = typeNameByIndex; + this.maxRows = maxRows; + this.valueReader = valueReader; + this.lastAccessedAtMillis = System.currentTimeMillis(); + } + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcIdentifiers.java b/agents/common/src/main/java/com/dbx/agent/JdbcIdentifiers.java new file mode 100644 index 0000000..92f34ec --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/JdbcIdentifiers.java @@ -0,0 +1,16 @@ +package com.dbx.agent; + +public final class JdbcIdentifiers { + public static final JdbcIdentifiers INSTANCE = new JdbcIdentifiers(); + + private JdbcIdentifiers() { + } + + public String doubleQuote(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + public String backtick(String identifier) { + return "`" + identifier.replace("`", "``") + "`"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java b/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java new file mode 100644 index 0000000..76ffa5a --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java @@ -0,0 +1,68 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.function.Function; + +final class JdbcSchemaSwitcher { + private JdbcSchemaSwitcher() { + } + + static void apply(Connection conn, String schema, Function setSchemaSql) throws Exception { + if (schema == null || schema.trim().isEmpty()) { + return; + } + + SchemaSqlResult schemaSqlResult = applySchemaSql(conn, schema, setSchemaSql); + Exception schemaSqlError = schemaSqlResult.error; + if (schemaSqlError == null) { + return; + } + + try { + conn.setSchema(schema); + return; + } catch (SQLException | AbstractMethodError ignored) { + // Some JDBC drivers only expose schema switching through SQL. + } + try { + conn.setCatalog(schema); + return; + } catch (SQLException | AbstractMethodError ignored) { + // Last fallback failed as well; surface the SQL-switch error below. + } + + if (schemaSqlResult.attempted) { + throw schemaSqlError; + } + } + + private static SchemaSqlResult applySchemaSql(Connection conn, String schema, Function setSchemaSql) { + String schemaSql; + try { + schemaSql = setSchemaSql.apply(schema); + } catch (RuntimeException e) { + return new SchemaSqlResult(true, e); + } + if (schemaSql == null || schemaSql.trim().isEmpty()) { + return new SchemaSqlResult(false, new SQLException("No schema switch SQL provided")); + } + try (Statement stmt = conn.createStatement()) { + stmt.execute(schemaSql); + return new SchemaSqlResult(true, null); + } catch (SQLException | AbstractMethodError e) { + return new SchemaSqlResult(true, e instanceof SQLException ? (SQLException) e : new SQLException(e)); + } + } + + private static final class SchemaSqlResult { + private final boolean attempted; + private final Exception error; + + private SchemaSqlResult(boolean attempted, Exception error) { + this.attempted = attempted; + this.error = error; + } + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java new file mode 100644 index 0000000..b978a90 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -0,0 +1,373 @@ +package com.dbx.agent; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; +import com.google.gson.reflect.TypeToken; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Connection; +import java.util.Collections; +import java.util.List; + +public final class JsonRpcServer { + private static final long CONNECTION_VALIDATION_INTERVAL_MILLIS = 5_000L; + + private final DatabaseAgent agent; + private final Gson gson = new GsonBuilder() + // JDBC DECIMAL/NUMERIC values can exceed JavaScript Number precision after JSON-RPC parsing. + .registerTypeAdapter( + BigDecimal.class, + (JsonSerializer) (value, type, context) -> new JsonPrimitive(value.toPlainString()) + ) + .registerTypeAdapter( + BigInteger.class, + (JsonSerializer) (value, type, context) -> new JsonPrimitive(value.toString()) + ) + .create(); + private ConnectParams lastConnectParams; + private long lastConnectionValidationTimeMillis; + + public JsonRpcServer(DatabaseAgent agent) { + this.agent = agent; + } + + public void run() { + System.out.println("{\"ready\":true}"); + System.out.flush(); + + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + while (true) { + String line = reader.readLine(); + if (line == null) { + break; + } + String response = handleRequest(line); + System.out.println(response); + System.out.flush(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + String handleRequest(String line) { + JsonObject req = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = req.get("id"); + String method = req.get("method").getAsString(); + JsonObject params = paramsObject(req); + + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + response.add("id", id); + + try { + Object result = dispatch(method, params); + response.add("result", gson.toJsonTree(result)); + return gson.toJson(response); + } catch (Throwable e) { + JsonObject error = new JsonObject(); + error.addProperty("code", -1); + error.addProperty("message", e.getMessage() == null ? e.toString() : e.getMessage()); + response.add("error", error); + return gson.toJson(response); + } + } + + private Object dispatch(String method, JsonObject params) throws Exception { + if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) { + return AgentProtocol.handshakeResult(); + } + if (AgentProtocol.METHOD_CONNECT.equals(method)) { + lastConnectParams = gson.fromJson(params, ConnectParams.class); + agent.connect(lastConnectParams); + lastConnectionValidationTimeMillis = 0L; + return Collections.singletonMap("ok", true); + } + if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) { + if (!agent.testConnection(gson.fromJson(params, ConnectParams.class))) { + throw new RuntimeException("Connection failed"); + } + return Collections.singletonMap("ok", true); + } + if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)) { + Connection conn = agent.getConnection(); + boolean valid = false; + if (conn != null) { + try { + valid = !conn.isClosed() && conn.isValid(2); + } catch (Exception ignored) { + } + } + if (!valid) { + throw new IllegalStateException("Connection is not valid"); + } + return Collections.singletonMap("ok", true); + } + ensureLiveConnection(method); + if (AgentProtocol.METHOD_LIST_DATABASES.equals(method)) { + return agent.listDatabases(); + } + if (AgentProtocol.METHOD_LIST_SCHEMAS.equals(method)) { + switchCatalog(params); + return agent.listSchemas(stringListOrNull(params, "visible_schemas")); + } + if (AgentProtocol.METHOD_LIST_TABLES.equals(method)) { + switchCatalog(params); + return agent.listTables(params.get("schema").getAsString(), metadataListConstraints(params)); + } + if (AgentProtocol.METHOD_LIST_OBJECTS.equals(method)) { + switchCatalog(params); + return agent.listObjects(params.get("schema").getAsString(), metadataListConstraints(params)); + } + if (AgentProtocol.METHOD_LIST_DATA_TYPES.equals(method)) { + switchCatalog(params); + return agent.listDataTypes(); + } + if (AgentProtocol.METHOD_COMPLETION_ASSISTANT_SEARCH_V1.equals(method)) { + switchCatalog(params); + return agent.completionAssistantSearch(gson.fromJson(params, CompletionAssistantRequest.class)); + } + if (AgentProtocol.METHOD_GET_OBJECT_SOURCE.equals(method)) { + switchCatalog(params); + return agent.getObjectSource( + params.get("schema").getAsString(), + params.get("name").getAsString(), + params.get("object_type").getAsString() + ); + } + if (AgentProtocol.METHOD_GET_TABLE_DDL.equals(method)) { + switchCatalog(params); + return agent.getTableDdl(params.get("schema").getAsString(), params.get("table").getAsString()); + } + if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) { + switchCatalog(params); + return agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString()); + } + if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) { + switchCatalog(params); + return agent.listIndexes(params.get("schema").getAsString(), params.get("table").getAsString()); + } + if (AgentProtocol.METHOD_LIST_FOREIGN_KEYS.equals(method)) { + switchCatalog(params); + return agent.listForeignKeys(params.get("schema").getAsString(), params.get("table").getAsString()); + } + if (AgentProtocol.METHOD_LIST_TRIGGERS.equals(method)) { + switchCatalog(params); + return agent.listTriggers(params.get("schema").getAsString(), params.get("table").getAsString()); + } + if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)) { + return agent.executeQuery( + params.get("sql").getAsString(), + stringOrNull(params, "schema"), + new ExecuteQueryOptions( + intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS), + intOrNull(params, "fetchSize"), + intOrDefault(params, "timeoutSecs", 0) + ) + ); + } + if (AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)) { + return agent.executeQueryPage( + params.get("sql").getAsString(), + stringOrNull(params, "schema"), + new QueryPageOptions( + intOrDefault(params, "pageSize", 100), + intOrNull(params, "fetchSize"), + intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS), + intOrDefault(params, "timeoutSecs", 0) + ) + ); + } + if (AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)) { + return agent.fetchQueryPage( + params.get("sessionId").getAsString(), + intOrDefault(params, "pageSize", 100) + ); + } + if (AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)) { + return agent.closeQuerySession(params.get("sessionId").getAsString()); + } + if (AgentProtocol.METHOD_START_TABLE_READ.equals(method)) { + return agent.startTableRead( + params.get("sql").getAsString(), + stringOrNull(params, "schema"), + new QueryPageOptions( + intOrDefault(params, "pageSize", 100), + intOrNull(params, "fetchSize"), + intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS), + intOrDefault(params, "timeoutSecs", 0) + ) + ); + } + if (AgentProtocol.METHOD_FETCH_TABLE_READ_PAGE.equals(method)) { + return agent.fetchTableReadPage( + params.get("sessionId").getAsString(), + intOrDefault(params, "pageSize", 100) + ); + } + if (AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION.equals(method)) { + return agent.closeTableReadSession(params.get("sessionId").getAsString()); + } + if (AgentProtocol.METHOD_GET_EXPLAIN_INFO.equals(method)) { + String plan = agent.getExplainInfo( + params.get("sql").getAsString(), + stringOrNull(params, "database"), + stringOrNull(params, "schema"), + intOrDefault(params, "timeoutSecs", -1), + stringOrNull(params, "mode") + ); + java.util.HashMap result = new java.util.HashMap<>(); + result.put("plan", plan); + result.put("has_actual_stats", "autotrace".equals(stringOrNull(params, "mode"))); + return result; + } + if (AgentProtocol.METHOD_EXECUTE_TRANSACTION.equals(method)) { + Type statementsType = new TypeToken>() {}.getType(); + List statements = gson.fromJson(params.get("statements"), statementsType); + return agent.executeTransaction(statements, stringOrNull(params, "schema")); + } + if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) { + Type statementsType = new TypeToken>() {}.getType(); + List statements = gson.fromJson(params.get("statements"), statementsType); + return agent.executeBatch(statements, stringOrNull(params, "schema")); + } + if (AgentProtocol.METHOD_DISCONNECT.equals(method)) { + JdbcExecutor.INSTANCE.closeAllQuerySessions(); + JdbcExecutor.INSTANCE.closeAllTableReadSessions(); + agent.disconnect(); + lastConnectParams = null; + return Collections.singletonMap("ok", true); + } + if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) { + JdbcExecutor.INSTANCE.closeAllQuerySessions(); + JdbcExecutor.INSTANCE.closeAllTableReadSessions(); + agent.disconnect(); + lastConnectParams = null; + System.exit(0); + return Collections.singletonMap("ok", true); + } + throw new IllegalArgumentException("Unknown method: " + method); + } + + private void switchCatalog(JsonObject params) throws Exception { + String database = stringOrNull(params, "database"); + if (database != null && !database.trim().isEmpty() && agent.getConnection() != null) { + String currentCatalog = null; + try { + currentCatalog = agent.getConnection().getCatalog(); + } catch (Exception ignored) { + } + if (currentCatalog == null || !currentCatalog.trim().equalsIgnoreCase(database.trim())) { + try { + agent.getConnection().setCatalog(database); + } catch (Exception ignored) { + } + } + } + } + + private void ensureLiveConnection(String method) { + if (lastConnectParams == null || !shouldValidateConnection(method)) { + return; + } + Connection conn = agent.getConnection(); + if (conn == null) { + return; + } + long now = System.currentTimeMillis(); + if (lastConnectionValidationTimeMillis > 0 + && now - lastConnectionValidationTimeMillis < CONNECTION_VALIDATION_INTERVAL_MILLIS) { + return; + } + boolean valid = false; + try { + valid = !conn.isClosed() && conn.isValid(2); + } catch (Exception ignored) { + } + if (valid) { + lastConnectionValidationTimeMillis = now; + return; + } + + JdbcExecutor.INSTANCE.closeAllQuerySessions(); + JdbcExecutor.INSTANCE.closeAllTableReadSessions(); + try { + agent.disconnect(); + } catch (Exception ignored) { + } + agent.connect(lastConnectParams); + lastConnectionValidationTimeMillis = System.currentTimeMillis(); + } + + private static boolean shouldValidateConnection(String method) { + return !AgentProtocol.METHOD_HANDSHAKE.equals(method) + && !AgentProtocol.METHOD_CONNECT.equals(method) + && !AgentProtocol.METHOD_TEST_CONNECTION.equals(method) + && !AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method) + && !AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method) + && !AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method) + && !AgentProtocol.METHOD_FETCH_TABLE_READ_PAGE.equals(method) + && !AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION.equals(method) + && !AgentProtocol.METHOD_DISCONNECT.equals(method) + && !AgentProtocol.METHOD_SHUTDOWN.equals(method); + } + + private static JsonObject paramsObject(JsonObject req) { + JsonElement params = req.get("params"); + if (params == null || params instanceof JsonNull || !params.isJsonObject()) { + return new JsonObject(); + } + return params.getAsJsonObject(); + } + + private static String stringOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + if (element == null || element instanceof JsonNull) { + return null; + } + return element.getAsString(); + } + + private List stringListOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + if (element == null || element instanceof JsonNull) { + return null; + } + Type listType = new TypeToken>() {}.getType(); + return gson.fromJson(element, listType); + } + + private static Integer intOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + if (element == null || element instanceof JsonNull) { + return null; + } + return element.getAsInt(); + } + + private MetadataListConstraints metadataListConstraints(JsonObject params) { + return new MetadataListConstraints( + stringOrNull(params, "filter"), + intOrNull(params, "limit"), + intOrNull(params, "offset"), + stringListOrNull(params, "object_types") + ); + } + + private static int intOrDefault(JsonObject object, String key, int defaultValue) { + Integer value = intOrNull(object, key); + return value == null ? defaultValue : value; + } + +} diff --git a/agents/common/src/main/java/com/dbx/agent/MetadataListConstraints.java b/agents/common/src/main/java/com/dbx/agent/MetadataListConstraints.java new file mode 100644 index 0000000..9e8b4f0 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/MetadataListConstraints.java @@ -0,0 +1,221 @@ +package com.dbx.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public final class MetadataListConstraints { + public static final MetadataListConstraints NONE = new MetadataListConstraints(null, null, null, null); + + private final String filter; + private final Integer limit; + private final Integer offset; + private final List objectTypes; + + public MetadataListConstraints(String filter, Integer limit, Integer offset, List objectTypes) { + this.filter = normalizeFilter(filter); + this.limit = limit == null || limit <= 0 ? null : limit; + this.offset = offset == null || offset <= 0 ? null : offset; + this.objectTypes = normalizeObjectTypes(objectTypes); + } + + public static MetadataListConstraints orNone(MetadataListConstraints constraints) { + return constraints == null ? NONE : constraints; + } + + public String getFilter() { + return filter; + } + + public Integer getLimit() { + return limit; + } + + public Integer getOffset() { + return offset; + } + + public List getObjectTypes() { + return objectTypes.isEmpty() ? null : objectTypes; + } + + public boolean hasFilter() { + return !filter.isEmpty(); + } + + public boolean hasLimit() { + return limit != null; + } + + public boolean hasOffset() { + return offset != null; + } + + public boolean hasObjectTypes() { + return !objectTypes.isEmpty(); + } + + public MetadataListConstraints withoutPaging() { + return new MetadataListConstraints(filter, null, null, objectTypes); + } + + public String fuzzyLikePattern() { + if (filter.isEmpty()) { + return "%%"; + } + StringBuilder builder = new StringBuilder(filter.length() * 2 + 2); + builder.append('%'); + for (int i = 0; i < filter.length(); i++) { + char ch = filter.charAt(i); + if (ch == '\\' || ch == '%' || ch == '_') { + builder.append('\\'); + } + builder.append(ch); + builder.append('%'); + } + return builder.toString(); + } + + public boolean includesTableLikeTypes() { + if (objectTypes.isEmpty()) { + return true; + } + return objectTypes.contains("TABLE") + || objectTypes.contains("VIEW") + || objectTypes.contains("MATERIALIZED_VIEW"); + } + + public boolean tableTypeAllowed(String tableType) { + if (objectTypes.isEmpty()) { + return true; + } + return objectTypes.contains(normalizeTableType(tableType)); + } + + public boolean objectTypeAllowed(String objectType) { + if (objectTypes.isEmpty()) { + return true; + } + String normalized = normalizeObjectType(objectType); + return !normalized.isEmpty() && objectTypes.contains(normalized); + } + + public boolean nameMatches(String name) { + return textMatches(name); + } + + public boolean nameOrCommentMatches(String name, String comment) { + return textMatches(name) || textMatches(comment); + } + + private boolean textMatches(String text) { + if (filter.isEmpty()) { + return true; + } + if (text == null) { + return false; + } + String candidate = text.toLowerCase(Locale.ROOT); + return candidate.contains(filter) || (filter.length() >= 2 && fuzzySubsequenceMatches(candidate, filter)); + } + + public List filterTables(List tables) { + List result = new ArrayList<>(); + int skipped = 0; + int max = limit == null ? Integer.MAX_VALUE : limit; + int start = offset == null ? 0 : offset; + for (TableInfo table : tables) { + if (!nameOrCommentMatches(table.getName(), table.getComment()) + || !tableTypeAllowed(table.getTable_type())) { + continue; + } + if (skipped++ < start) { + continue; + } + if (result.size() >= max) { + break; + } + result.add(table); + } + return result; + } + + public List filterObjects(List objects) { + List result = new ArrayList<>(); + int skipped = 0; + int max = limit == null ? Integer.MAX_VALUE : limit; + int start = offset == null ? 0 : offset; + for (ObjectInfo object : objects) { + if (!nameOrCommentMatches(object.getName(), object.getComment()) + || !objectTypeAllowed(object.getObject_type())) { + continue; + } + if (skipped++ < start) { + continue; + } + if (result.size() >= max) { + break; + } + result.add(object); + } + return result; + } + + private static String normalizeFilter(String value) { + if (value == null) { + return ""; + } + return value.trim().toLowerCase(Locale.ROOT); + } + + private static List normalizeObjectTypes(List values) { + if (values == null || values.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (String value : values) { + String normalized = normalizeObjectType(value); + if (!normalized.isEmpty() && !result.contains(normalized)) { + result.add(normalized); + } + } + Collections.sort(result); + return Collections.unmodifiableList(result); + } + + private static String normalizeTableType(String value) { + String normalized = normalizeObjectType(value); + return normalized.isEmpty() ? "TABLE" : normalized; + } + + private static String normalizeObjectType(String value) { + if (value == null || value.trim().isEmpty()) { + return ""; + } + String upper = value.trim().toUpperCase(Locale.ROOT).replace(' ', '_'); + if (upper.contains("MATERIALIZED") && upper.contains("VIEW")) { + return "MATERIALIZED_VIEW"; + } + if (upper.equals("STABLE") || upper.equals("BASE_TABLE") || upper.contains("TABLE")) { + return "TABLE"; + } + if (upper.contains("VIEW")) { + return "VIEW"; + } + return upper; + } + + private static boolean fuzzySubsequenceMatches(String candidate, String expected) { + int cursor = 0; + for (int i = 0; i < expected.length(); i++) { + char ch = expected.charAt(i); + cursor = candidate.indexOf(ch, cursor); + if (cursor < 0) { + return false; + } + cursor++; + } + return true; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/MetadataSqlSupport.java b/agents/common/src/main/java/com/dbx/agent/MetadataSqlSupport.java new file mode 100644 index 0000000..c5c85e0 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/MetadataSqlSupport.java @@ -0,0 +1,95 @@ +package com.dbx.agent; + +import java.sql.PreparedStatement; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public final class MetadataSqlSupport { + private MetadataSqlSupport() { + } + + public static void appendNameFilter(StringBuilder sql, List args, String column, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (!normalized.hasFilter()) { + return; + } + sql.append(" AND UPPER(").append(column).append(") LIKE ? ESCAPE '\\\\'"); + args.add(normalized.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + + public static void appendLimitOffset(StringBuilder sql, List args, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (!normalized.hasLimit()) { + return; + } + sql.append(" LIMIT ?"); + args.add(normalized.getLimit()); + if (normalized.hasOffset()) { + sql.append(" OFFSET ?"); + args.add(normalized.getOffset()); + } + } + + public static void appendOffsetFetch(StringBuilder sql, List args, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (normalized.hasOffset()) { + sql.append(" OFFSET ? ROWS"); + args.add(normalized.getOffset()); + } + if (normalized.hasLimit()) { + sql.append(normalized.hasOffset() ? " FETCH NEXT ? ROWS ONLY" : " FETCH FIRST ? ROWS ONLY"); + args.add(normalized.getLimit()); + } + } + + public static void appendLiteralLimitOffset(StringBuilder sql, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (!normalized.hasLimit()) { + return; + } + sql.append(" LIMIT ").append(normalized.getLimit()); + if (normalized.hasOffset()) { + sql.append(" OFFSET ").append(normalized.getOffset()); + } + } + + public static void appendLiteralOffsetFetch(StringBuilder sql, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (normalized.hasOffset()) { + sql.append(" OFFSET ").append(normalized.getOffset()).append(" ROWS"); + } + if (normalized.hasLimit()) { + sql.append(normalized.hasOffset() ? " FETCH NEXT " : " FETCH FIRST ") + .append(normalized.getLimit()) + .append(" ROWS ONLY"); + } + } + + public static void appendLiteralSkipFirst(StringBuilder sql, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (normalized.hasOffset()) { + sql.append("SKIP ").append(normalized.getOffset()).append(' '); + } + if (normalized.hasLimit()) { + sql.append("FIRST ").append(normalized.getLimit()).append(' '); + } + } + + public static String placeholders(int count) { + return String.join(", ", Collections.nCopies(count, "?")); + } + + public static void bind(PreparedStatement stmt, List args) throws Exception { + for (int index = 0; index < args.size(); index += 1) { + Object arg = args.get(index); + if (arg instanceof Integer) { + stmt.setInt(index + 1, (Integer) arg); + } else if (arg == null) { + stmt.setObject(index + 1, null); + } else { + stmt.setString(index + 1, String.valueOf(arg)); + } + } + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java b/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java new file mode 100644 index 0000000..00b2639 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ObjectInfo.java @@ -0,0 +1,82 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class ObjectInfo { + private String name; + private String object_type; + private String schema; + private String comment; + + public ObjectInfo() { + this("", "", null, null); + } + + public ObjectInfo(String name, String object_type) { + this(name, object_type, null, null); + } + + public ObjectInfo(String name, String object_type, String schema, String comment) { + this.name = name; + this.object_type = object_type; + this.schema = schema; + this.comment = comment; + } + + public String getName() { + return name; + } + + public String getObject_type() { + return object_type; + } + + public String getSchema() { + return schema; + } + + public String getComment() { + return comment; + } + + public void setName(String name) { + this.name = name; + } + + public void setObject_type(String object_type) { + this.object_type = object_type; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public void setComment(String comment) { + this.comment = comment; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ObjectInfo)) return false; + ObjectInfo that = (ObjectInfo) other; + return Objects.equals(name, that.name) + && Objects.equals(object_type, that.object_type) + && Objects.equals(schema, that.schema) + && Objects.equals(comment, that.comment); + } + + @Override + public int hashCode() { + return Objects.hash(name, object_type, schema, comment); + } + + @Override + public String toString() { + return "ObjectInfo(name=" + name + + ", object_type=" + object_type + + ", schema=" + schema + + ", comment=" + comment + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/ObjectSource.java b/agents/common/src/main/java/com/dbx/agent/ObjectSource.java new file mode 100644 index 0000000..13e9d25 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/ObjectSource.java @@ -0,0 +1,98 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class ObjectSource { + private String name; + private String object_type; + private String schema; + private String source; + private boolean editable; + + public ObjectSource() { + this("", "", null, "", true); + } + + public ObjectSource(String name, String object_type, String source) { + this(name, object_type, null, source, true); + } + + public ObjectSource(String name, String object_type, String schema, String source) { + this(name, object_type, schema, source, true); + } + + public ObjectSource(String name, String object_type, String schema, String source, boolean editable) { + this.name = name; + this.object_type = object_type; + this.schema = schema; + this.source = source; + this.editable = editable; + } + + public String getName() { + return name; + } + + public String getObject_type() { + return object_type; + } + + public String getSchema() { + return schema; + } + + public String getSource() { + return source; + } + + public boolean isEditable() { + return editable; + } + + public void setName(String name) { + this.name = name; + } + + public void setObject_type(String object_type) { + this.object_type = object_type; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public void setSource(String source) { + this.source = source; + } + + public void setEditable(boolean editable) { + this.editable = editable; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ObjectSource)) return false; + ObjectSource that = (ObjectSource) other; + return Objects.equals(name, that.name) + && Objects.equals(object_type, that.object_type) + && Objects.equals(schema, that.schema) + && Objects.equals(source, that.source) + && editable == that.editable; + } + + @Override + public int hashCode() { + return Objects.hash(name, object_type, schema, source, editable); + } + + @Override + public String toString() { + return "ObjectSource(name=" + name + + ", object_type=" + object_type + + ", schema=" + schema + + ", source=" + source + + ", editable=" + editable + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java new file mode 100644 index 0000000..fa3bd15 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java @@ -0,0 +1,554 @@ +package com.dbx.agent; + +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public abstract class PostgresLikeAgent extends AbstractJdbcAgent { + private final PostgresLikeAgentProfile profile; + private boolean mysqlCompatMode = false; + + protected PostgresLikeAgent(PostgresLikeAgentProfile profile) { + this.profile = profile; + } + + public PostgresLikeAgentProfile getProfile() { + return profile; + } + + public void setMysqlCompatMode(boolean mysqlCompatMode) { + this.mysqlCompatMode = mysqlCompatMode; + } + + public boolean isMysqlCompatMode() { + return mysqlCompatMode; + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + geometryAwareResolver() + ); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.executePage( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + geometryAwareResolver() + ); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.startTableRead( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + geometryAwareResolver() + ); + } + + /** + * Wrap {@link AbstractJdbcAgent#resultValue} so PostGIS-style {@code geometry} + * and {@code geography} columns are decoded into WKT (matching the native + * tokio_postgres path in {@code crates/dbx-core/src/db/postgres.rs}). + */ + private JdbcExecutor.ColumnAwareResultValueReader geometryAwareResolver() { + return (rs, index, sqlType, columnTypeName) -> { + if (isPostgisGeometryTypeName(columnTypeName)) { + Object raw = rs.getObject(index); + if (rs.wasNull() || raw == null) { + return null; + } + return EwkbWktDecoder.decode(raw); + } + return resultValue(rs, index, sqlType, columnTypeName); + }; + } + + protected Object resultValue(ResultSet rs, int index, int sqlType, String columnTypeName) { + return resultValue(rs, index, sqlType); + } + + static boolean isPostgisGeometryTypeName(String columnTypeName) { + if (columnTypeName == null) { + return false; + } + String trimmed = columnTypeName.trim().toLowerCase(Locale.ROOT); + if (trimmed.isEmpty()) { + return false; + } + // Strip optional schema prefix (e.g. "public.geometry") or "(srid,type)" suffix. + int dot = trimmed.lastIndexOf('.'); + if (dot >= 0 && dot < trimmed.length() - 1) { + trimmed = trimmed.substring(dot + 1); + } + int paren = trimmed.indexOf('('); + if (paren >= 0) { + trimmed = trimmed.substring(0, paren); + } + return "geometry".equals(trimmed) || "geography".equals(trimmed); + } + + private String quoteIdentifier(String identifier) { + return mysqlCompatMode + ? JdbcIdentifiers.INSTANCE.backtick(identifier) + : JdbcIdentifiers.INSTANCE.doubleQuote(identifier); + } + + @Override + protected String driverClass() { + return profile.getDriverClass(); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return profile.buildUrl(params); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString("datname"))); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement( + "SELECT n.nspname AS schema_name " + + "FROM pg_catalog.pg_namespace n " + + "WHERE n.nspname NOT IN ('pg_catalog','information_schema','pg_toast') " + + "AND n.nspname NOT LIKE 'pg_toast_temp_%' " + + "AND n.nspname NOT LIKE 'pg_temp_%' " + + "ORDER BY n.nspname" + ); ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(rs.getString("schema_name")); + } + } + return result; + }); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement( + "SELECT c.relname AS table_name, " + + "CASE c.relkind " + + "WHEN 'r' THEN 'TABLE' " + + "WHEN 'p' THEN 'TABLE' " + + "WHEN 'v' THEN 'VIEW' " + + "WHEN 'm' THEN 'MATERIALIZED VIEW' " + + "WHEN 'f' THEN 'FOREIGN TABLE' " + + "ELSE 'TABLE' END AS table_type, " + + "obj_description(c.oid) AS table_comment " + + "FROM pg_catalog.pg_class c " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "WHERE n.nspname = ? AND c.relkind IN ('r','p','v','m','f') " + + "ORDER BY c.relname" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String tableType = normalizeTableType(rs.getString("table_type")); + result.add(new TableInfo(rs.getString("table_name"), tableType, rs.getString("table_comment"))); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement( + "SELECT p.proname AS routine_name, " + + "CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type " + + "FROM pg_catalog.pg_proc p " + + "JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace " + + "WHERE n.nspname = ? AND p.prokind IN ('p','f') " + + "ORDER BY p.proname" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement( + "SELECT c.relname AS sequence_name, 'SEQUENCE' AS object_type " + + "FROM pg_catalog.pg_class c " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "WHERE n.nspname = ? AND c.relkind = 'S' " + + "ORDER BY c.relname" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public String getTableDdl(String schema, String table) { + List indexes; + try { + indexes = listIndexes(schema, table); + } catch (RuntimeException e) { + indexes = Collections.emptyList(); + } + + List foreignKeys; + try { + foreignKeys = listForeignKeys(schema, table); + } catch (RuntimeException e) { + foreignKeys = Collections.emptyList(); + } + + List checkConstraints; + try { + checkConstraints = listCheckConstraints(schema, table); + } catch (RuntimeException e) { + // Some PostgreSQL-compatible databases expose incomplete catalog APIs. + // DDL generation should still succeed with columns, indexes, and foreign keys. + checkConstraints = Collections.emptyList(); + } + + return DdlBuilder.buildTableDdl( + schema, + table, + getColumns(schema, table), + indexes, + foreignKeys, + checkConstraints, + mysqlCompatMode, + true + ); + } + + protected List listCheckConstraints(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT co.conname AS constraint_name, pg_catalog.pg_get_constraintdef(co.oid, true) AS constraint_definition " + + "FROM pg_catalog.pg_constraint co " + + "JOIN pg_catalog.pg_class c ON c.oid = co.conrelid " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "WHERE co.contype = 'c' AND n.nspname = ? AND c.relname = ? " + + "ORDER BY co.conname"; + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new CheckConstraintInfo( + rs.getString("constraint_name"), + rs.getString("constraint_definition") + )); + } + } + } + return result; + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String upperType = objectType.toUpperCase(); + String sql; + if ("VIEW".equals(upperType) || "MATERIALIZED VIEW".equals(upperType)) { + sql = "SELECT pg_get_viewdef(to_regclass(?), true)"; + } else if ("FUNCTION".equals(upperType)) { + sql = "SELECT pg_get_functiondef(p.oid)\n" + + "FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n" + + "WHERE n.nspname = ? AND p.proname = ? AND p.prokind = 'f'\n" + + "ORDER BY p.oid LIMIT 1"; + } else if ("PROCEDURE".equals(upperType)) { + sql = "SELECT pg_get_functiondef(p.oid)\n" + + "FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n" + + "WHERE n.nspname = ? AND p.proname = ? AND p.prokind = 'p'\n" + + "ORDER BY p.oid LIMIT 1"; + } else { + throw new IllegalArgumentException("Unsupported object type: " + objectType); + } + + String source; + if ("VIEW".equals(upperType) || "MATERIALIZED VIEW".equals(upperType)) { + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, quoteQualifiedIdentifier(schema, name)); + try (ResultSet rs = stmt.executeQuery()) { + source = rs.next() ? coalesce(rs.getString(1)) : ""; + } + } + } else { + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + source = rs.next() ? coalesce(rs.getString(1)) : ""; + } + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Set primaryKeys = primaryKeys(schema, table); + List result = new ArrayList<>(); + String sql = "SELECT a.attname AS column_name, " + + "format_type(a.atttypid, a.atttypmod) AS data_type, " + + "NOT a.attnotnull AS is_nullable, " + + "pg_get_expr(ad.adbin, ad.adrelid) AS column_default, " + + "col_description(a.attrelid, a.attnum) AS column_comment, " + + "CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 " + + "THEN ((a.atttypmod - 4) >> 16) & 65535 ELSE NULL END AS numeric_precision, " + + "CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 " + + "THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, " + + "CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 " + + "THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length " + + "FROM pg_catalog.pg_attribute a " + + "JOIN pg_catalog.pg_type t ON t.oid = a.atttypid " + + "JOIN pg_catalog.pg_class c ON c.oid = a.attrelid " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum " + + "WHERE n.nspname = ? AND c.relname = ? " + + "AND a.attnum > 0 AND NOT a.attisdropped " + + "ORDER BY a.attnum"; + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String colName = rs.getString("column_name"); + result.add(new ColumnInfo( + colName, + rs.getString("data_type"), + rs.getBoolean("is_nullable"), + rs.getString("column_default"), + primaryKeys.contains(colName), + null, + rs.getString("column_comment"), + intObject(rs, "numeric_precision"), + intObject(rs, "numeric_scale"), + intObject(rs, "character_maximum_length") + )); + } + } + } + return result; + }); + } + + private Set primaryKeys(String schema, String table) { + return unchecked(() -> { + Set primaryKeys = new LinkedHashSet<>(); + String sql = "SELECT a.attname AS column_name " + + "FROM pg_catalog.pg_constraint co " + + "JOIN pg_catalog.pg_class c ON c.oid = co.conrelid " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "JOIN LATERAL (SELECT unnest(co.conkey) AS attnum, generate_series(1, array_length(co.conkey, 1)) AS ord) AS pk_cols ON true " + + "JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = pk_cols.attnum " + + "WHERE co.contype = 'p' " + + "AND n.nspname = ? " + + "AND c.relname = ? " + + "ORDER BY pk_cols.ord"; + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + primaryKeys.add(rs.getString("column_name")); + } + } + } + return primaryKeys; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " + + "ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " + + "array_agg(a.attname ORDER BY k.n) AS columns " + + "FROM pg_index ix " + + "JOIN pg_class t ON t.oid = ix.indrelid " + + "JOIN pg_class i ON i.oid = ix.indexrelid " + + "JOIN pg_namespace n ON n.oid = t.relnamespace " + + "JOIN pg_am am ON am.oid = i.relam " + + "JOIN LATERAL (SELECT unnest(ix.indkey) AS attnum, generate_series(1, array_length(ix.indkey, 1)) AS n) AS k ON true " + + "JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum " + + "WHERE n.nspname = ? AND t.relname = ? " + + "GROUP BY i.relname, am.amname, ix.indisunique, ix.indisprimary " + + "ORDER BY i.relname"; + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Object[] columnArray = (Object[]) rs.getArray("columns").getArray(); + List columns = new ArrayList<>(); + for (Object column : columnArray) { + columns.add(String.valueOf(column)); + } + result.add(new IndexInfo( + rs.getString("index_name"), + columns, + rs.getBoolean("is_unique"), + rs.getBoolean("is_primary"), + null, + rs.getString("index_type"), + null, + null + )); + } + } + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT co.conname AS constraint_name, " + + "a.attname AS column_name, " + + "rc.relname AS ref_table, " + + "ra.attname AS ref_column " + + "FROM pg_catalog.pg_constraint co " + + "JOIN pg_catalog.pg_class c ON c.oid = co.conrelid " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "JOIN pg_catalog.pg_class rc ON rc.oid = co.confrelid " + + "JOIN LATERAL (SELECT unnest(co.conkey) AS attnum, generate_series(1, array_length(co.conkey, 1)) AS ord) AS fk ON true " + + "JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = fk.attnum " + + "JOIN LATERAL (SELECT unnest(co.confkey) AS attnum, generate_series(1, array_length(co.confkey, 1)) AS ord) AS pk ON pk.ord = fk.ord " + + "JOIN pg_catalog.pg_attribute ra ON ra.attrelid = rc.oid AND ra.attnum = pk.attnum " + + "WHERE co.contype = 'f' " + + "AND n.nspname = ? " + + "AND c.relname = ? " + + "ORDER BY co.conname, fk.ord"; + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("constraint_name"), + rs.getString("column_name"), + rs.getString("ref_table"), + rs.getString("ref_column") + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT tg.tgname AS trigger_name, " + + "trim(trailing ',' FROM (" + + "CASE WHEN (tg.tgtype & 4) <> 0 THEN 'INSERT,' ELSE '' END || " + + "CASE WHEN (tg.tgtype & 8) <> 0 THEN 'DELETE,' ELSE '' END || " + + "CASE WHEN (tg.tgtype & 16) <> 0 THEN 'UPDATE,' ELSE '' END || " + + "CASE WHEN (tg.tgtype & 32) <> 0 THEN 'TRUNCATE,' ELSE '' END" + + ")) AS event_manipulation, " + + "CASE WHEN (tg.tgtype & 2) <> 0 THEN 'BEFORE' ELSE 'AFTER' END AS action_timing " + + "FROM pg_catalog.pg_trigger tg " + + "JOIN pg_catalog.pg_class c ON c.oid = tg.tgrelid " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "WHERE n.nspname = ? AND c.relname = ? AND NOT tg.tgisinternal " + + "ORDER BY tg.tgname"; + try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo( + rs.getString("trigger_name"), + rs.getString("event_manipulation"), + rs.getString("action_timing") + )); + } + } + } + return result; + }); + } + + @Override + public String setSchemaSQL(String schema) { + return "SET search_path TO " + quoteIdentifier(schema); + } + + private java.sql.Connection requireConnection() { + return requireConnected(); + } + + private static String normalizeTableType(String type) { + if (type == null || type.trim().isEmpty()) return "TABLE"; + if ("BASE TABLE".equals(type)) return "TABLE"; + return type; + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + private String quoteQualifiedIdentifier(String schema, String name) { + return quoteIdentifier(schema) + "." + quoteIdentifier(name); + } + + private static Integer intObject(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgentProfile.java b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgentProfile.java new file mode 100644 index 0000000..f4ba2a8 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgentProfile.java @@ -0,0 +1,30 @@ +package com.dbx.agent; + +public final class PostgresLikeAgentProfile { + private final String driverClass; + private final String urlTemplate; + + public PostgresLikeAgentProfile(String driverClass, String urlTemplate) { + this.driverClass = driverClass; + this.urlTemplate = urlTemplate; + } + + public String getDriverClass() { + return driverClass; + } + + public String getUrlTemplate() { + return urlTemplate; + } + + public String buildUrl(ConnectParams params) { + return new JdbcAgentProfile( + driverClass, + urlTemplate, + 0, + false, + java.util.Collections.emptySet(), + java.util.Arrays.asList("TABLE", "VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE", "SYSTEM VIEW") + ).buildUrl(params); + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/QueryPageOptions.java b/agents/common/src/main/java/com/dbx/agent/QueryPageOptions.java new file mode 100644 index 0000000..293c7aa --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/QueryPageOptions.java @@ -0,0 +1,82 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class QueryPageOptions { + private int pageSize; + private Integer fetchSize; + private int maxRows; + private int timeoutSecs; + + public QueryPageOptions() { + this(100, null, JdbcExecutor.DEFAULT_MAX_ROWS, 0); + } + + public QueryPageOptions(int pageSize, Integer fetchSize, int maxRows) { + this(pageSize, fetchSize, maxRows, 0); + } + + public QueryPageOptions(int pageSize, Integer fetchSize, int maxRows, int timeoutSecs) { + this.pageSize = pageSize; + this.fetchSize = fetchSize; + this.maxRows = maxRows; + this.timeoutSecs = timeoutSecs; + } + + public int getPageSize() { + return pageSize; + } + + public Integer getFetchSize() { + return fetchSize; + } + + public int getMaxRows() { + return maxRows; + } + + public int getTimeoutSecs() { + return timeoutSecs; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } + + public void setFetchSize(Integer fetchSize) { + this.fetchSize = fetchSize; + } + + public void setMaxRows(int maxRows) { + this.maxRows = maxRows; + } + + public void setTimeoutSecs(int timeoutSecs) { + this.timeoutSecs = timeoutSecs; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof QueryPageOptions)) return false; + QueryPageOptions that = (QueryPageOptions) other; + return pageSize == that.pageSize + && maxRows == that.maxRows + && timeoutSecs == that.timeoutSecs + && Objects.equals(fetchSize, that.fetchSize); + } + + @Override + public int hashCode() { + return Objects.hash(pageSize, fetchSize, maxRows, timeoutSecs); + } + + @Override + public String toString() { + return "QueryPageOptions(pageSize=" + pageSize + + ", fetchSize=" + fetchSize + + ", maxRows=" + maxRows + + ", timeoutSecs=" + timeoutSecs + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/QueryPageParams.java b/agents/common/src/main/java/com/dbx/agent/QueryPageParams.java new file mode 100644 index 0000000..a0ab76b --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/QueryPageParams.java @@ -0,0 +1,90 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class QueryPageParams { + private String sql; + private String schema; + private Integer pageSize; + private Integer fetchSize; + private Integer maxRows; + + public QueryPageParams() { + this("", null, null, null, null); + } + + public QueryPageParams(String sql, String schema, Integer pageSize, Integer fetchSize, Integer maxRows) { + this.sql = sql; + this.schema = schema; + this.pageSize = pageSize; + this.fetchSize = fetchSize; + this.maxRows = maxRows; + } + + public String getSql() { + return sql; + } + + public String getSchema() { + return schema; + } + + public Integer getPageSize() { + return pageSize; + } + + public Integer getFetchSize() { + return fetchSize; + } + + public Integer getMaxRows() { + return maxRows; + } + + public void setSql(String sql) { + this.sql = sql; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public void setPageSize(Integer pageSize) { + this.pageSize = pageSize; + } + + public void setFetchSize(Integer fetchSize) { + this.fetchSize = fetchSize; + } + + public void setMaxRows(Integer maxRows) { + this.maxRows = maxRows; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof QueryPageParams)) return false; + QueryPageParams that = (QueryPageParams) other; + return Objects.equals(sql, that.sql) + && Objects.equals(schema, that.schema) + && Objects.equals(pageSize, that.pageSize) + && Objects.equals(fetchSize, that.fetchSize) + && Objects.equals(maxRows, that.maxRows); + } + + @Override + public int hashCode() { + return Objects.hash(sql, schema, pageSize, fetchSize, maxRows); + } + + @Override + public String toString() { + return "QueryPageParams(sql=" + sql + + ", schema=" + schema + + ", pageSize=" + pageSize + + ", fetchSize=" + fetchSize + + ", maxRows=" + maxRows + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java b/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java new file mode 100644 index 0000000..051cb0a --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java @@ -0,0 +1,163 @@ +package com.dbx.agent; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class QueryPageResult { + private List columns; + private List column_types; + private List> rows; + private long affected_rows; + private long execution_time_ms; + private boolean truncated; + private String session_id; + private boolean has_more; + + public QueryPageResult() { + this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false, null, false); + } + + public QueryPageResult(List columns, List> rows, long affected_rows, long execution_time_ms) { + this(columns, rows, affected_rows, execution_time_ms, false, null, false); + } + + public QueryPageResult( + List columns, + List> rows, + long affected_rows, + long execution_time_ms, + boolean truncated + ) { + this(columns, rows, affected_rows, execution_time_ms, truncated, null, false); + } + + public QueryPageResult( + List columns, + List> rows, + long affected_rows, + long execution_time_ms, + boolean truncated, + String session_id, + boolean has_more + ) { + this(columns, null, rows, affected_rows, execution_time_ms, truncated, session_id, has_more); + } + + public QueryPageResult( + List columns, + List column_types, + List> rows, + long affected_rows, + long execution_time_ms, + boolean truncated, + String session_id, + boolean has_more + ) { + this.columns = columns == null ? Collections.emptyList() : columns; + this.column_types = column_types == null ? Collections.emptyList() : column_types; + this.rows = QueryResult.normalizeRows(rows); + this.affected_rows = affected_rows; + this.execution_time_ms = execution_time_ms; + this.truncated = truncated; + this.session_id = session_id; + this.has_more = has_more; + } + + public List getColumns() { + return columns; + } + + public List getColumn_types() { + return column_types; + } + + public List> getRows() { + return rows; + } + + public long getAffected_rows() { + return affected_rows; + } + + public long getExecution_time_ms() { + return execution_time_ms; + } + + public boolean getTruncated() { + return truncated; + } + + public String getSession_id() { + return session_id; + } + + public boolean getHas_more() { + return has_more; + } + + public void setColumns(List columns) { + this.columns = columns; + } + + public void setColumn_types(List column_types) { + this.column_types = column_types; + } + + public void setRows(List> rows) { + this.rows = rows; + } + + public void setAffected_rows(long affected_rows) { + this.affected_rows = affected_rows; + } + + public void setExecution_time_ms(long execution_time_ms) { + this.execution_time_ms = execution_time_ms; + } + + public void setTruncated(boolean truncated) { + this.truncated = truncated; + } + + public void setSession_id(String session_id) { + this.session_id = session_id; + } + + public void setHas_more(boolean has_more) { + this.has_more = has_more; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof QueryPageResult)) return false; + QueryPageResult that = (QueryPageResult) other; + return affected_rows == that.affected_rows + && execution_time_ms == that.execution_time_ms + && truncated == that.truncated + && has_more == that.has_more + && Objects.equals(columns, that.columns) + && Objects.equals(column_types, that.column_types) + && Objects.equals(rows, that.rows) + && Objects.equals(session_id, that.session_id); + } + + @Override + public int hashCode() { + return Objects.hash(columns, column_types, rows, affected_rows, execution_time_ms, truncated, session_id, has_more); + } + + @Override + public String toString() { + return "QueryPageResult(columns=" + columns + + ", column_types=" + column_types + + ", rows=" + rows + + ", affected_rows=" + affected_rows + + ", execution_time_ms=" + execution_time_ms + + ", truncated=" + truncated + + ", session_id=" + session_id + + ", has_more=" + has_more + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/QueryResult.java b/agents/common/src/main/java/com/dbx/agent/QueryResult.java new file mode 100644 index 0000000..c38881a --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/QueryResult.java @@ -0,0 +1,137 @@ +package com.dbx.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class QueryResult { + private List columns; + private List column_types; + private List> rows; + private long affected_rows; + private long execution_time_ms; + private boolean truncated; + + public QueryResult() { + this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false); + } + + public QueryResult(List columns, List> rows, long affected_rows, long execution_time_ms) { + this(columns, rows, affected_rows, execution_time_ms, false); + } + + public QueryResult( + List columns, + List> rows, + long affected_rows, + long execution_time_ms, + boolean truncated + ) { + this(columns, null, rows, affected_rows, execution_time_ms, truncated); + } + + public QueryResult( + List columns, + List column_types, + List> rows, + long affected_rows, + long execution_time_ms, + boolean truncated + ) { + this.columns = columns == null ? Collections.emptyList() : columns; + this.column_types = column_types == null ? Collections.emptyList() : column_types; + this.rows = normalizeRows(rows); + this.affected_rows = affected_rows; + this.execution_time_ms = execution_time_ms; + this.truncated = truncated; + } + + public List getColumns() { + return columns; + } + + public List getColumn_types() { + return column_types; + } + + public List> getRows() { + return rows; + } + + public long getAffected_rows() { + return affected_rows; + } + + public long getExecution_time_ms() { + return execution_time_ms; + } + + public boolean getTruncated() { + return truncated; + } + + public void setColumns(List columns) { + this.columns = columns; + } + + public void setColumn_types(List column_types) { + this.column_types = column_types; + } + + public void setRows(List> rows) { + this.rows = rows; + } + + public void setAffected_rows(long affected_rows) { + this.affected_rows = affected_rows; + } + + public void setExecution_time_ms(long execution_time_ms) { + this.execution_time_ms = execution_time_ms; + } + + public void setTruncated(boolean truncated) { + this.truncated = truncated; + } + + static List> normalizeRows(List> input) { + if (input == null) { + return Collections.emptyList(); + } + List> normalized = new ArrayList<>(input.size()); + for (List row : input) { + normalized.add(row == null ? Collections.emptyList() : new ArrayList(row)); + } + return normalized; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof QueryResult)) return false; + QueryResult that = (QueryResult) other; + return affected_rows == that.affected_rows + && execution_time_ms == that.execution_time_ms + && truncated == that.truncated + && Objects.equals(columns, that.columns) + && Objects.equals(column_types, that.column_types) + && Objects.equals(rows, that.rows); + } + + @Override + public int hashCode() { + return Objects.hash(columns, column_types, rows, affected_rows, execution_time_ms, truncated); + } + + @Override + public String toString() { + return "QueryResult(columns=" + columns + + ", column_types=" + column_types + + ", rows=" + rows + + ", affected_rows=" + affected_rows + + ", execution_time_ms=" + execution_time_ms + + ", truncated=" + truncated + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/SchemaTableParams.java b/agents/common/src/main/java/com/dbx/agent/SchemaTableParams.java new file mode 100644 index 0000000..fc389c6 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/SchemaTableParams.java @@ -0,0 +1,51 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class SchemaTableParams { + private String schema; + private String table; + + public SchemaTableParams() { + this("", ""); + } + + public SchemaTableParams(String schema, String table) { + this.schema = schema; + this.table = table; + } + + public String getSchema() { + return schema; + } + + public String getTable() { + return table; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public void setTable(String table) { + this.table = table; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof SchemaTableParams)) return false; + SchemaTableParams that = (SchemaTableParams) other; + return Objects.equals(schema, that.schema) && Objects.equals(table, that.table); + } + + @Override + public int hashCode() { + return Objects.hash(schema, table); + } + + @Override + public String toString() { + return "SchemaTableParams(schema=" + schema + ", table=" + table + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java b/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java new file mode 100644 index 0000000..9a6eada --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/StandardJdbcMetadata.java @@ -0,0 +1,716 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class StandardJdbcMetadata { + public static final StandardJdbcMetadata INSTANCE = new StandardJdbcMetadata(); + + private StandardJdbcMetadata() { + } + + public List listDatabases(Connection conn, String configuredDatabase) { + return unchecked(() -> { + Set names = new LinkedHashSet<>(); + try { + try (ResultSet rs = conn.getMetaData().getCatalogs()) { + while (rs.next()) { + addNonBlank(names, rs.getString("TABLE_CAT")); + } + } + } catch (Exception ignored) { + } + addNonBlank(names, configuredDatabase); + addNonBlank(names, conn.getCatalog()); + + List result = new ArrayList<>(); + for (String name : names) { + result.add(new DatabaseInfo(name)); + } + return result; + }); + } + + public List listSchemas(Connection conn, JdbcAgentProfile profile) { + return unchecked(() -> { + Set names = new LinkedHashSet<>(); + DatabaseMetaData meta = conn.getMetaData(); + try { + appendSchemas(names, meta.getSchemas(null, null)); + } catch (Exception | AbstractMethodError first) { + try { + appendSchemas(names, meta.getSchemas()); + } catch (Exception | AbstractMethodError ignored) { + } + } + try { + addNonBlank(names, conn.getSchema()); + } catch (Exception | AbstractMethodError ignored) { + } + + List result = new ArrayList<>(); + for (String name : names) { + if (!profile.getExcludedSchemas().contains(name.toUpperCase(Locale.ROOT))) { + result.add(name); + } + } + Collections.sort(result); + return result; + }); + } + + public List listTables(Connection conn, JdbcAgentProfile profile, String configuredDatabase, String schema) { + return listTables(conn, profile, configuredDatabase, schema, MetadataListConstraints.NONE); + } + + public List listTables( + Connection conn, + JdbcAgentProfile profile, + String configuredDatabase, + String schema, + MetadataListConstraints constraints + ) { + return unchecked(() -> { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (!normalized.includesTableLikeTypes()) { + return Collections.emptyList(); + } + DatabaseMetaData meta = conn.getMetaData(); + List result = new ArrayList<>(); + appendTables(result, meta, profile, null, schema, normalized); + if (result.isEmpty() && profile.getCatalogFallbackEnabled() && !configuredDatabase.trim().isEmpty()) { + appendTables(result, meta, profile, configuredDatabase, schema, normalized); + } + result.sort(Comparator.comparing(TableInfo::getName)); + return normalized.filterTables(result); + }); + } + + public List listObjects(List tables, String schema) { + return listObjects(tables, schema, MetadataListConstraints.NONE); + } + + public List listObjects(List tables, String schema, MetadataListConstraints constraints) { + List result = new ArrayList<>(); + for (TableInfo table : tables) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + return MetadataListConstraints.orNone(constraints).filterObjects(result); + } + + public List listObjects( + Connection conn, + JdbcAgentProfile profile, + String configuredDatabase, + String schema, + MetadataListConstraints constraints + ) { + return unchecked(() -> { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + MetadataListConstraints tableConstraints = + new MetadataListConstraints(normalized.getFilter(), null, null, normalized.getObjectTypes()); + List result = new ArrayList<>( + listObjects(listTables(conn, profile, configuredDatabase, schema, tableConstraints), schema) + ); + DatabaseMetaData meta = conn.getMetaData(); + appendRoutines(result, meta, null, blankToNull(schema), schema); + if (!containsRoutine(result) + && profile.getCatalogFallbackEnabled() + && configuredDatabase != null + && !configuredDatabase.trim().isEmpty()) { + appendRoutines(result, meta, configuredDatabase, blankToNull(schema), schema); + } + result.sort(Comparator.comparing(ObjectInfo::getName)); + return normalized.filterObjects(result); + }); + } + + public List listDataTypes(Connection conn) { + return unchecked(() -> { + Set seen = new LinkedHashSet<>(); + List names = new ArrayList<>(); + try (ResultSet rs = conn.getMetaData().getTypeInfo()) { + while (rs.next()) { + String name = rs.getString("TYPE_NAME"); + if (name == null || name.trim().isEmpty()) { + continue; + } + String trimmed = name.trim(); + if (seen.add(trimmed.toLowerCase(Locale.ROOT))) { + names.add(trimmed); + } + } + } + return names; + }); + } + + public CompletionAssistantResponse completionAssistantSearch( + Connection conn, + JdbcAgentProfile profile, + String configuredDatabase, + CompletionAssistantRequest request + ) { + return unchecked(() -> { + int limit = boundedLimit(request.getMax_results()); + List candidates = new ArrayList<>(); + List kinds = normalizedObjectKinds(request); + DatabaseMetaData meta = conn.getMetaData(); + String catalog = blankToNull(request.getDatabase()); + String schema = completionSchema(request); + + if (containsKind(kinds, CompletionAssistantObjectKind.SCHEMA)) { + appendCompletionSchemas(candidates, meta, profile, request, limit); + } + if (candidates.size() < limit && containsTableLike(kinds)) { + appendCompletionTables(candidates, meta, profile, catalog, schema, request, kinds, limit); + if (candidates.isEmpty() && profile.getCatalogFallbackEnabled() && configuredDatabase != null && !configuredDatabase.trim().isEmpty()) { + appendCompletionTables(candidates, meta, profile, configuredDatabase, schema, request, kinds, limit); + } + } + if (candidates.size() < limit && containsKind(kinds, CompletionAssistantObjectKind.COLUMN)) { + appendCompletionColumns(candidates, meta, catalog, schema, request, limit); + if (candidates.isEmpty() && profile.getCatalogFallbackEnabled() && configuredDatabase != null && !configuredDatabase.trim().isEmpty()) { + appendCompletionColumns(candidates, meta, configuredDatabase, schema, request, limit); + } + } + + return new CompletionAssistantResponse(candidates, candidates.size() >= limit, false); + }); + } + + public List getColumns(Connection conn, JdbcAgentProfile profile, String configuredDatabase, String schema, String table) { + return unchecked(() -> { + DatabaseMetaData meta = conn.getMetaData(); + Set primaryKeys = primaryKeys(meta, null, schema, table); + List result = new ArrayList<>(); + appendColumns(result, meta, null, schema, table, primaryKeys); + if (!result.isEmpty() && primaryKeys.isEmpty() && profile.getCatalogFallbackEnabled() && hasConfiguredDatabase(configuredDatabase)) { + Set fallbackPrimaryKeys = primaryKeys(meta, configuredDatabase, schema, table); + markPrimaryKeys(result, fallbackPrimaryKeys); + } + if (result.isEmpty() && profile.getCatalogFallbackEnabled() && hasConfiguredDatabase(configuredDatabase)) { + Set fallbackPrimaryKeys = primaryKeys(meta, configuredDatabase, schema, table); + appendColumns(result, meta, configuredDatabase, schema, table, fallbackPrimaryKeys); + } + return result; + }); + } + + public List listIndexes(Connection conn, String schema, String table) { + return unchecked(() -> { + DatabaseMetaData meta = conn.getMetaData(); + Map> indexes = new LinkedHashMap<>(); + Map unique = new LinkedHashMap<>(); + try (ResultSet rs = meta.getIndexInfo(null, blankToNull(schema), table, false, false)) { + while (rs.next()) { + String name = rs.getString("INDEX_NAME"); + String column = rs.getString("COLUMN_NAME"); + if (name == null || column == null) { + continue; + } + indexes.computeIfAbsent(name, ignored -> new ArrayList<>()).add( + new IndexColumn(rs.getShort("ORDINAL_POSITION"), column) + ); + unique.put(name, !rs.getBoolean("NON_UNIQUE")); + } + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : indexes.entrySet()) { + List orderedColumns = entry.getValue(); + orderedColumns.sort(Comparator.comparingInt(IndexColumn::getOrdinal)); + List columns = new ArrayList<>(); + for (IndexColumn column : orderedColumns) { + columns.add(column.getName()); + } + String name = entry.getKey(); + result.add(new IndexInfo( + name, + columns, + Boolean.TRUE.equals(unique.get(name)), + "PRIMARY".equalsIgnoreCase(name), + null, + null, + null, + null + )); + } + return result; + }); + } + + public List listForeignKeys(Connection conn, String schema, String table) { + return unchecked(() -> { + DatabaseMetaData meta = conn.getMetaData(); + List result = new ArrayList<>(); + try (ResultSet rs = meta.getImportedKeys(null, blankToNull(schema), table)) { + while (rs.next()) { + String name = rs.getString("FK_NAME"); + result.add(new ForeignKeyInfo( + name == null ? "" : name, + rs.getString("FKCOLUMN_NAME"), + rs.getString("PKTABLE_NAME"), + rs.getString("PKCOLUMN_NAME") + )); + } + } + return result; + }); + } + + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + private static String[] getDriverTableTypes(DatabaseMetaData meta, JdbcAgentProfile profile) throws Exception { + Set configuredTypes = new LinkedHashSet<>(); + for (String type : profile.getTableTypes()) { + if (type != null) { + configuredTypes.add(type.toUpperCase(Locale.ROOT)); + } + } + try (ResultSet rs = meta.getTableTypes()) { + List types = new ArrayList<>(); + while (rs.next()) { + String type = rs.getString("TABLE_TYPE"); + if (type != null && configuredTypes.contains(type.toUpperCase(Locale.ROOT))) { + types.add(type); + } + } + if (!types.isEmpty()) { + return types.toArray(new String[0]); + } + } catch (Exception ignored) { + } + return profile.getTableTypes().toArray(new String[profile.getTableTypes().size()]); + } + + public static String getIdentifierQuote(Connection conn, JdbcAgentProfile profile) { + try { + String quote = conn.getMetaData().getIdentifierQuoteString(); + if (quote != null && !quote.trim().isEmpty()) { + return quote.trim(); + } + } catch (Exception ignored) { + } + return profile.getIdentifierQuote(); + } + + public static String quoteIdentifier(String identifier, String quote) { + return quote + identifier.replace(quote, quote + quote) + quote; + } + + public static String schemaSwitchSql(Connection conn, JdbcAgentProfile profile, String schema) { + if (profile.getSkipExecutionContext()) { + return profile.schemaSwitchSql(schema); + } + String quote = getIdentifierQuote(conn, profile); + return profile.schemaSwitchSql(schema, quote); + } + + private void appendTables( + List result, + DatabaseMetaData meta, + JdbcAgentProfile profile, + String catalog, + String schema, + MetadataListConstraints constraints + ) throws Exception { + String[] tableTypes = constrainedTableTypes(getDriverTableTypes(meta, profile), constraints); + if (tableTypes.length == 0) { + return; + } + // JDBC has no portable metadata limit/offset. Use table types for safe pushdown and filter/page locally. + try (ResultSet rs = meta.getTables(catalog, blankToNull(schema), "%", tableTypes)) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("TABLE_NAME"), + normalizeTableType(rs.getString("TABLE_TYPE")), + rs.getString("REMARKS") + )); + } + } + } + + private static String[] constrainedTableTypes(String[] tableTypes, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (!normalized.hasObjectTypes()) { + return tableTypes; + } + List result = new ArrayList<>(); + for (String tableType : tableTypes) { + if (normalized.tableTypeAllowed(tableType)) { + result.add(tableType); + } + } + return result.toArray(new String[0]); + } + + private void appendCompletionSchemas( + List result, + DatabaseMetaData meta, + JdbcAgentProfile profile, + CompletionAssistantRequest request, + int limit + ) throws Exception { + Set names = new LinkedHashSet<>(); + try { + appendSchemas(names, meta.getSchemas(null, null)); + } catch (Exception | AbstractMethodError first) { + try { + appendSchemas(names, meta.getSchemas()); + } catch (Exception | AbstractMethodError ignored) { + } + } + List sorted = new ArrayList<>(names); + Collections.sort(sorted); + for (String name : sorted) { + if (result.size() >= limit) { + return; + } + if (profile.getExcludedSchemas().contains(name.toUpperCase(Locale.ROOT)) || !completionNameMatches(name, request)) { + continue; + } + result.add(new CompletionAssistantCandidate( + name, + CompletionAssistantCandidateKind.SCHEMA, + request.getDatabase(), + name, + null, + null, + null, + null + )); + } + } + + private void appendCompletionTables( + List result, + DatabaseMetaData meta, + JdbcAgentProfile profile, + String catalog, + String schema, + CompletionAssistantRequest request, + List kinds, + int limit + ) throws Exception { + String[] tableTypes = getDriverTableTypes(meta, profile); + try (ResultSet rs = meta.getTables(catalog, blankToNull(schema), completionPattern(request), tableTypes)) { + while (rs.next() && result.size() < limit) { + String name = rs.getString("TABLE_NAME"); + String type = normalizeTableType(rs.getString("TABLE_TYPE")); + CompletionAssistantCandidateKind kind = completionTableKind(type); + if (!completionTableKindAllowed(kind, kinds) || !completionNameMatches(name, request)) { + continue; + } + result.add(new CompletionAssistantCandidate( + name, + kind, + request.getDatabase(), + schema, + null, + null, + rs.getString("REMARKS"), + null + )); + } + } + result.sort(Comparator.comparing(CompletionAssistantCandidate::getName)); + } + + private void appendCompletionColumns( + List result, + DatabaseMetaData meta, + String catalog, + String schema, + CompletionAssistantRequest request, + int limit + ) throws Exception { + String table = request.getParent_name(); + if (table == null || table.trim().isEmpty()) { + return; + } + try (ResultSet rs = meta.getColumns(catalog, blankToNull(schema), table, completionPattern(request))) { + while (rs.next() && result.size() < limit) { + String name = rs.getString("COLUMN_NAME"); + if (!completionNameMatches(name, request)) { + continue; + } + result.add(new CompletionAssistantCandidate( + name, + CompletionAssistantCandidateKind.COLUMN, + request.getDatabase(), + schema, + schema, + table, + rs.getString("REMARKS"), + rs.getString("TYPE_NAME") + )); + } + } + } + + private static List normalizedObjectKinds(CompletionAssistantRequest request) { + List kinds = request.getObject_kinds(); + if (kinds.isEmpty()) { + kinds = new ArrayList<>(); + kinds.add(CompletionAssistantObjectKind.TABLE); + kinds.add(CompletionAssistantObjectKind.VIEW); + } + return kinds; + } + + private static String completionSchema(CompletionAssistantRequest request) { + String parentSchema = request.getParent_schema(); + if (parentSchema != null && !parentSchema.trim().isEmpty()) { + return parentSchema; + } + return request.getSchema(); + } + + private static String completionPattern(CompletionAssistantRequest request) { + String mask = request.getMask(); + if (mask.trim().isEmpty()) { + return "%"; + } + String escaped = mask.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + if (request.getMatch_mode() == CompletionAssistantMatchMode.CONTAINS) { + return "%" + escaped + "%"; + } + return escaped + "%"; + } + + private static boolean completionNameMatches(String name, CompletionAssistantRequest request) { + if (name == null) { + return false; + } + String mask = request.getMask(); + if (mask.trim().isEmpty()) { + return true; + } + String candidate = request.getCase_sensitive() ? name : name.toLowerCase(Locale.ROOT); + String expected = request.getCase_sensitive() ? mask : mask.toLowerCase(Locale.ROOT); + if (request.getMatch_mode() == CompletionAssistantMatchMode.CONTAINS) { + return candidate.contains(expected); + } + return candidate.startsWith(expected); + } + + private static int boundedLimit(Integer requested) { + if (requested == null) { + return 100; + } + return Math.max(1, Math.min(1000, requested)); + } + + private static boolean containsKind(List kinds, CompletionAssistantObjectKind kind) { + return kinds.contains(kind); + } + + private static boolean containsTableLike(List kinds) { + return kinds.contains(CompletionAssistantObjectKind.TABLE) || kinds.contains(CompletionAssistantObjectKind.VIEW); + } + + private static CompletionAssistantCandidateKind completionTableKind(String type) { + if ("VIEW".equalsIgnoreCase(type)) { + return CompletionAssistantCandidateKind.VIEW; + } + return CompletionAssistantCandidateKind.TABLE; + } + + private static boolean completionTableKindAllowed( + CompletionAssistantCandidateKind kind, + List requestedKinds + ) { + if (kind == CompletionAssistantCandidateKind.VIEW) { + return requestedKinds.contains(CompletionAssistantObjectKind.VIEW); + } + return requestedKinds.contains(CompletionAssistantObjectKind.TABLE); + } + + private Set primaryKeys(DatabaseMetaData meta, String catalog, String schema, String table) { + Set keys = new LinkedHashSet<>(); + try (ResultSet rs = meta.getPrimaryKeys(catalog, blankToNull(schema), table)) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + if (name != null) { + keys.add(name); + } + } + } catch (Exception ignored) { + } + return keys; + } + + private static boolean hasConfiguredDatabase(String configuredDatabase) { + return configuredDatabase != null && !configuredDatabase.trim().isEmpty(); + } + + private static void markPrimaryKeys(List columns, Set primaryKeys) { + if (primaryKeys.isEmpty()) { + return; + } + for (ColumnInfo column : columns) { + if (primaryKeys.contains(column.getName())) { + column.setIs_primary_key(true); + } + } + } + + private static void appendSchemas(Set names, ResultSet rs) throws Exception { + try (ResultSet closeable = rs) { + while (closeable.next()) { + addNonBlank(names, closeable.getString("TABLE_SCHEM")); + } + } + } + + private void appendColumns( + List result, + DatabaseMetaData meta, + String catalog, + String schema, + String table, + Set primaryKeys + ) throws Exception { + try (ResultSet rs = meta.getColumns(catalog, blankToNull(schema), table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + name, + rs.getString("TYPE_NAME"), + rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + rs.getString("COLUMN_DEF"), + primaryKeys.contains(name), + null, + rs.getString("REMARKS"), + intOrNull(rs, "COLUMN_SIZE"), + intOrNull(rs, "DECIMAL_DIGITS"), + characterLength(rs) + )); + } + } + } + + private static String normalizeTableType(String type) { + if (type == null || type.trim().isEmpty()) { + return "TABLE"; + } + String normalized = type.toUpperCase(Locale.ROOT); + if ("BASE TABLE".equals(normalized) || "COLUMN TABLE".equals(normalized) || "ROW TABLE".equals(normalized)) { + return "TABLE"; + } + return type; + } + + private static void appendRoutines( + List result, + DatabaseMetaData meta, + String catalog, + String schemaPattern, + String schema + ) { + Set procedureNames = new LinkedHashSet<>(); + try (ResultSet rs = meta.getProcedures(catalog, schemaPattern, "%")) { + while (rs.next()) { + String name = rs.getString("PROCEDURE_NAME"); + if (name != null && !name.trim().isEmpty()) { + procedureNames.add(name); + result.add(new ObjectInfo(name, "PROCEDURE", schema, rs.getString("REMARKS"))); + } + } + } catch (Exception | AbstractMethodError ignored) { + // Some JDBC drivers don't implement routine metadata. + } + try (ResultSet rs = meta.getFunctions(catalog, schemaPattern, "%")) { + while (rs.next()) { + String name = rs.getString("FUNCTION_NAME"); + if (name != null && !name.trim().isEmpty() && !procedureNames.contains(name)) { + result.add(new ObjectInfo(name, "FUNCTION", schema, rs.getString("REMARKS"))); + } + } + } catch (Exception | AbstractMethodError ignored) { + // Some JDBC drivers don't implement function metadata separately. + } + } + + private static boolean containsRoutine(List objects) { + for (ObjectInfo object : objects) { + if ("PROCEDURE".equalsIgnoreCase(object.getObject_type()) + || "FUNCTION".equalsIgnoreCase(object.getObject_type())) { + return true; + } + } + return false; + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static Integer characterLength(ResultSet rs) throws Exception { + String typeName = rs.getString("TYPE_NAME"); + if (typeName == null) { + return null; + } + String normalized = typeName.toLowerCase(Locale.ROOT); + if (!normalized.contains("char") && !normalized.contains("text")) { + return null; + } + return intOrNull(rs, "COLUMN_SIZE"); + } + + private static String blankToNull(String value) { + return value == null || value.trim().isEmpty() ? null : value; + } + + private static void addNonBlank(Set values, String value) { + if (value != null && !value.trim().isEmpty()) { + values.add(value); + } + } + + private static T unchecked(ThrowingSupplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static final class IndexColumn { + private final int ordinal; + private final String name; + + private IndexColumn(int ordinal, String name) { + this.ordinal = ordinal; + this.name = name; + } + + private int getOrdinal() { + return ordinal; + } + + private String getName() { + return name; + } + } + + private interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/TableInfo.java b/agents/common/src/main/java/com/dbx/agent/TableInfo.java new file mode 100644 index 0000000..0b38595 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/TableInfo.java @@ -0,0 +1,98 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class TableInfo { + private String name; + private String table_type; + private String comment; + private String parent_schema; + private String parent_name; + + public TableInfo() { + this("", "", null); + } + + public TableInfo(String name, String table_type) { + this(name, table_type, null); + } + + public TableInfo(String name, String table_type, String comment) { + this(name, table_type, comment, null, null); + } + + public TableInfo(String name, String table_type, String comment, String parent_schema, String parent_name) { + this.name = name; + this.table_type = table_type; + this.comment = comment; + this.parent_schema = parent_schema; + this.parent_name = parent_name; + } + + public String getName() { + return name; + } + + public String getTable_type() { + return table_type; + } + + public String getComment() { + return comment; + } + + public String getParent_schema() { + return parent_schema; + } + + public String getParent_name() { + return parent_name; + } + + public void setName(String name) { + this.name = name; + } + + public void setTable_type(String table_type) { + this.table_type = table_type; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public void setParent_schema(String parent_schema) { + this.parent_schema = parent_schema; + } + + public void setParent_name(String parent_name) { + this.parent_name = parent_name; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof TableInfo)) return false; + TableInfo that = (TableInfo) other; + return Objects.equals(name, that.name) + && Objects.equals(table_type, that.table_type) + && Objects.equals(comment, that.comment) + && Objects.equals(parent_schema, that.parent_schema) + && Objects.equals(parent_name, that.parent_name); + } + + @Override + public int hashCode() { + return Objects.hash(name, table_type, comment, parent_schema, parent_name); + } + + @Override + public String toString() { + return "TableInfo(name=" + name + + ", table_type=" + table_type + + ", comment=" + comment + + ", parent_schema=" + parent_schema + + ", parent_name=" + parent_name + + ")"; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/TransactionExecutor.java b/agents/common/src/main/java/com/dbx/agent/TransactionExecutor.java new file mode 100644 index 0000000..2bd12ed --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/TransactionExecutor.java @@ -0,0 +1,112 @@ +package com.dbx.agent; + +import java.sql.Connection; +import java.sql.Statement; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +public final class TransactionExecutor { + private TransactionExecutor() { + } + + public static QueryResult executeUpdateStatements( + Connection conn, + List statements, + String schema, + Function setSchemaSql + ) { + return executeStatements(conn, statements, schema, setSchemaSql, new StatementRunner() { + @Override + public long run(Statement stmt, String sql) throws Exception { + return stmt.executeUpdate(sql); + } + }); + } + + public static QueryResult executeStatements( + Connection conn, + List statements, + String schema, + Function setSchemaSql, + StatementRunner runner + ) { + return unchecked(() -> { + long start = System.currentTimeMillis(); + if (!supportsTransactions(conn)) { + long totalAffected = executeAll(conn, statements, schema, setSchemaSql, runner); + return result(totalAffected, start); + } + + boolean savedAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + try { + long totalAffected = executeAll(conn, statements, schema, setSchemaSql, runner); + conn.commit(); + return result(totalAffected, start); + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(savedAutoCommit); + } + }); + } + + private static long executeAll( + Connection conn, + List statements, + String schema, + Function setSchemaSql, + StatementRunner runner + ) throws Exception { + applySchema(conn, schema, setSchemaSql); + long totalAffected = 0; + for (String statement : statements) { + try (Statement stmt = conn.createStatement()) { + totalAffected += runner.run(stmt, JdbcExecutor.trimSql(statement)); + } + } + return totalAffected; + } + + private static void applySchema(Connection conn, String schema, Function setSchemaSql) throws Exception { + JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql); + } + + private static boolean supportsTransactions(Connection conn) { + try { + return conn.getMetaData().supportsTransactions(); + } catch (Exception ignored) { + return true; + } + } + + private static QueryResult result(long totalAffected, long start) { + return new QueryResult( + Collections.emptyList(), + Collections.emptyList(), + totalAffected, + System.currentTimeMillis() - start, + false + ); + } + + private static T unchecked(ThrowingSupplier supplier) { + try { + return supplier.get(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public interface StatementRunner { + long run(Statement stmt, String sql) throws Exception; + } + + private interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/agents/common/src/main/java/com/dbx/agent/TriggerInfo.java b/agents/common/src/main/java/com/dbx/agent/TriggerInfo.java new file mode 100644 index 0000000..490b55a --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/TriggerInfo.java @@ -0,0 +1,63 @@ +package com.dbx.agent; + +import java.util.Objects; + +public final class TriggerInfo { + private String name; + private String event; + private String timing; + + public TriggerInfo() { + this("", "", ""); + } + + public TriggerInfo(String name, String event, String timing) { + this.name = name; + this.event = event; + this.timing = timing; + } + + public String getName() { + return name; + } + + public String getEvent() { + return event; + } + + public String getTiming() { + return timing; + } + + public void setName(String name) { + this.name = name; + } + + public void setEvent(String event) { + this.event = event; + } + + public void setTiming(String timing) { + this.timing = timing; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof TriggerInfo)) return false; + TriggerInfo that = (TriggerInfo) other; + return Objects.equals(name, that.name) + && Objects.equals(event, that.event) + && Objects.equals(timing, that.timing); + } + + @Override + public int hashCode() { + return Objects.hash(name, event, timing); + } + + @Override + public String toString() { + return "TriggerInfo(name=" + name + ", event=" + event + ", timing=" + timing + ")"; + } +} diff --git a/agents/common/src/main/resources/agent-protocol-v1.json b/agents/common/src/main/resources/agent-protocol-v1.json new file mode 100644 index 0000000..c108483 --- /dev/null +++ b/agents/common/src/main/resources/agent-protocol-v1.json @@ -0,0 +1,88 @@ +{ + "protocolVersion": 1, + "handshakeMethod": "handshake", + "handshakeResponseFields": [ + "protocolVersion", + "agentProtocolVersion", + "capabilities" + ], + "allCapabilities": [ + "connect", + "test_connection", + "metadata", + "query", + "paged_query", + "transaction", + "ddl", + "kv" + ], + "capabilities": [ + "connect", + "test_connection", + "metadata", + "query", + "paged_query", + "transaction", + "ddl" + ], + "defaultSqlCapabilities": [ + "connect", + "test_connection", + "metadata", + "query", + "paged_query", + "transaction", + "ddl" + ], + "commonMethods": [ + "handshake", + "connect", + "test_connection", + "validate_connection", + "list_databases", + "list_schemas", + "list_tables", + "list_objects", + "list_data_types", + "completion_assistant_search_v1", + "get_object_source", + "get_table_ddl", + "get_columns", + "list_indexes", + "list_foreign_keys", + "list_triggers", + "execute_query", + "execute_query_page", + "fetch_query_page", + "close_query_session", + "start_table_read", + "fetch_table_read_page", + "close_table_read_session", + "get_explain_info", + "execute_batch", + "execute_transaction", + "disconnect", + "shutdown" + ], + "mongoLegacyMethods": [ + "list_databases", + "list_collections", + "find_documents", + "find_documents_extended_json", + "server_version", + "create_index", + "drop_indexes", + "drop_collection", + "insert_document", + "update_document", + "update_documents", + "delete_document", + "delete_documents" + ], + "kvMethods": [ + "kv_list_prefix", + "kv_get", + "kv_put", + "kv_delete" + ] +} diff --git a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java new file mode 100644 index 0000000..2043c63 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java @@ -0,0 +1,481 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractJdbcAgentTest { + @Test + void ownsConnectionLifecycleAndConnectedState() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + + assertThrows(IllegalStateException.class, () -> agent.executeQuery("SELECT 1", null, new ExecuteQueryOptions())); + + agent.connect(new ConnectParams("localhost", 0, "demo", "user", "secret", "", "", false)); + + assertNotNull(agent.getConnection()); + assertEquals("demo", agent.database()); + assertEquals(1, tracking.openCount); + assertEquals(1, agent.afterConnectCount); + + agent.disconnect(); + + assertNull(agent.getConnection()); + assertEquals(1, tracking.closeCount); + } + + @Test + void testsConnectionsThroughSharedLifecycle() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + + assertTrue(agent.testConnection(new ConnectParams())); + + assertEquals(1, tracking.openCount); + assertEquals(1, tracking.isValidCount); + assertEquals(1, tracking.closeCount); + } + + @Test + void delegatesQueryExecutionWithSchemaAndValueReader() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + QueryResult result = agent.executeQuery("SELECT VALUE", "APP", new ExecuteQueryOptions(25, 7)); + + assertEquals(Collections.singletonList("VALUE"), result.getColumns()); + assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows()); + assertFalse(result.getTruncated()); + assertEquals(Arrays.asList("execute:USE APP", "setMaxRows:26", "setFetchSize:7", "execute:SELECT VALUE"), tracking.calls); + } + + @Test + void delegatesQueryTimeoutToStatement() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + agent.executeQuery("SELECT VALUE", null, new ExecuteQueryOptions(25, 7, 12)); + + assertEquals(Arrays.asList("setMaxRows:26", "setQueryTimeout:12", "setFetchSize:7", "execute:SELECT VALUE"), tracking.calls); + } + + @Test + void delegatesPagedQueryTimeoutToStatement() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + agent.executeQueryPage("SELECT VALUE", null, new QueryPageOptions(10, 7, 25, 12)); + + assertEquals(Arrays.asList("setQueryTimeout:12", "setFetchSize:7", "execute:SELECT VALUE"), tracking.calls); + } + + @Test + void preservesPlSqlBlockTerminatorDuringQueryExecution() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + agent.executeQuery("BEGIN\n NULL;\nEND;\n/", null, new ExecuteQueryOptions()); + + assertEquals(Arrays.asList("setMaxRows:10001", "execute:BEGIN\n NULL;\nEND;"), tracking.calls); + } + + @Test + void executesTransactionControlSqlThroughStatements() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + // Dump files can contain explicit transaction SQL while the JDBC connection is in auto-commit mode. + agent.executeQuery("BEGIN;", null, new ExecuteQueryOptions()); + agent.executeQuery("BEGIN TRANSACTION;", null, new ExecuteQueryOptions()); + agent.executeQuery("COMMIT;", null, new ExecuteQueryOptions()); + agent.executeQuery("ROLLBACK;", null, new ExecuteQueryOptions()); + + assertEquals( + Arrays.asList( + "setMaxRows:10001", + "execute:BEGIN", + "setMaxRows:10001", + "execute:BEGIN TRANSACTION", + "setMaxRows:10001", + "execute:COMMIT", + "setMaxRows:10001", + "execute:ROLLBACK" + ), + tracking.calls + ); + } + + @Test + void executesPagedTransactionControlSqlThroughStatements() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + agent.executeQueryPage("COMMIT;", null, new QueryPageOptions()); + + assertEquals(Collections.singletonList("execute:COMMIT"), tracking.calls); + } + + @Test + void delegatesTransactionsThroughSharedFoundation() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + QueryResult result = agent.executeTransaction(Arrays.asList("UPDATE A;", "UPDATE B"), "APP"); + + assertEquals(2L, result.getAffected_rows()); + assertEquals( + Arrays.asList( + "setAutoCommit:false", + "execute:USE APP", + "executeUpdate:UPDATE A", + "executeUpdate:UPDATE B", + "commit", + "setAutoCommit:true" + ), + tracking.calls + ); + } + + @Test + void preservesPlSqlBlockTerminatorDuringTransactionExecution() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + + agent.executeTransaction(Collections.singletonList("DECLARE\n v NUMBER;\nBEGIN\n NULL;\nEND;"), null); + + assertEquals( + Arrays.asList( + "setAutoCommit:false", + "executeUpdate:DECLARE\n v NUMBER;\nBEGIN\n NULL;\nEND;", + "commit", + "setAutoCommit:true" + ), + tracking.calls + ); + } + + @Test + void inheritedDdlFallbackToleratesMissingOptionalMetadata() { + TestAgent agent = new TestAgent(new TrackingConnection()) { + @Override + public List getColumns(String schema, String table) { + return Collections.singletonList(new ColumnInfo("ID", "INTEGER", false, null, true)); + } + + @Override + public List listIndexes(String schema, String table) { + throw new RuntimeException("indexes unavailable"); + } + + @Override + public List listForeignKeys(String schema, String table) { + throw new RuntimeException("foreign keys unavailable"); + } + }; + + assertEquals( + "CREATE TABLE \"APP\".\"ORDERS\" (\n" + + " \"ID\" INTEGER NOT NULL,\n" + + " PRIMARY KEY (\"ID\")\n" + + ");\n", + agent.getTableDdl("APP", "ORDERS") + ); + } + + private static class TestAgent extends AbstractJdbcAgent { + private final TrackingConnection tracking; + private int afterConnectCount; + + private TestAgent(TrackingConnection tracking) { + this.tracking = tracking; + } + + @Override + protected String driverClass() { + return TestAgent.class.getName(); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return "jdbc:test"; + } + + @Override + protected Connection openConnection(ConnectParams params) { + tracking.openCount += 1; + return tracking.connection(); + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) { + afterConnectCount += 1; + } + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public List listSchemas() { + return Collections.emptyList(); + } + + @Override + public List listTables(String schema) { + return Collections.emptyList(); + } + + @Override + public List getColumns(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + schema; + } + + private String database() { + return getConfiguredDatabase(); + } + } + + private static final class TrackingConnection { + private final List calls = new ArrayList(); + private int openCount; + private int closeCount; + private int isValidCount; + private boolean autoCommit = true; + + private Connection connection() { + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("createStatement".equals(name)) { + return statement(); + } + if ("getMetaData".equals(name)) { + return databaseMetaData(); + } + if ("isValid".equals(name)) { + isValidCount += 1; + return true; + } + if ("close".equals(name)) { + closeCount += 1; + return null; + } + if ("getAutoCommit".equals(name)) { + return autoCommit; + } + if ("setSchema".equals(name)) { + calls.add("setSchema:" + args[0]); + return null; + } + if ("setCatalog".equals(name)) { + calls.add("setCatalog:" + args[0]); + return null; + } + if ("setAutoCommit".equals(name)) { + autoCommit = (Boolean) args[0]; + calls.add("setAutoCommit:" + args[0]); + return null; + } + if ("commit".equals(name)) { + calls.add("commit"); + return null; + } + if ("rollback".equals(name)) { + calls.add("rollback"); + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private DatabaseMetaData databaseMetaData() { + return proxy(DatabaseMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("supportsTransactions".equals(method.getName())) { + return true; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private Statement statement() { + return proxy(Statement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("execute".equals(name)) { + calls.add("execute:" + args[0]); + return true; + } + if ("getResultSet".equals(name)) { + return resultSet(); + } + if ("getUpdateCount".equals(name)) { + return 1; + } + if ("executeUpdate".equals(name)) { + calls.add("executeUpdate:" + args[0]); + return 1; + } + if ("setMaxRows".equals(name)) { + calls.add("setMaxRows:" + args[0]); + return null; + } + if ("setFetchSize".equals(name)) { + calls.add("setFetchSize:" + args[0]); + return null; + } + if ("setQueryTimeout".equals(name)) { + calls.add("setQueryTimeout:" + args[0]); + return null; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private ResultSet resultSet() { + final int[] index = {-1}; + return proxy(ResultSet.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] == 0; + } + if ("getMetaData".equals(name)) { + return resultSetMetaData(); + } + if ("getString".equals(name)) { + return "row-value"; + } + if ("wasNull".equals(name)) { + return false; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private ResultSetMetaData resultSetMetaData() { + return proxy(ResultSetMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("getColumnCount".equals(name)) { + return 1; + } + if ("getColumnLabel".equals(name)) { + return "VALUE"; + } + if ("getColumnType".equals(name)) { + return Types.VARCHAR; + } + return defaultValue(method.getReturnType()); + } + }); + } + } + + private static T proxy(Class type, final MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/BatchExecutorTest.java b/agents/common/src/test/java/com/dbx/agent/BatchExecutorTest.java new file mode 100644 index 0000000..4500e34 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/BatchExecutorTest.java @@ -0,0 +1,100 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BatchExecutorTest { + @Test + void executeBatchStatementsUsesJdbcBatch() { + List batchedSql = new ArrayList<>(); + AtomicInteger executeLargeBatchCalls = new AtomicInteger(); + AtomicInteger executeUpdateCalls = new AtomicInteger(); + + Statement statement = statementProxy(batchedSql, executeLargeBatchCalls, executeUpdateCalls); + Connection connection = connectionProxy(statement); + + QueryResult result = BatchExecutor.executeBatchStatements( + connection, + Arrays.asList(" INSERT INTO items VALUES (1); ", "UPDATE items SET name = 'Ada' WHERE id = 1;"), + null, + schema -> null + ); + + assertEquals(Arrays.asList("INSERT INTO items VALUES (1)", "UPDATE items SET name = 'Ada' WHERE id = 1"), batchedSql); + assertEquals(1, executeLargeBatchCalls.get()); + assertEquals(0, executeUpdateCalls.get()); + assertEquals(2L, result.getAffected_rows()); + } + + private static Statement statementProxy( + List batchedSql, + AtomicInteger executeLargeBatchCalls, + AtomicInteger executeUpdateCalls + ) { + InvocationHandler handler = (Object unused, Method method, Object[] args) -> { + switch (method.getName()) { + case "addBatch": + batchedSql.add((String) args[0]); + return null; + case "executeLargeBatch": + executeLargeBatchCalls.incrementAndGet(); + return new long[]{1L, Statement.SUCCESS_NO_INFO}; + case "executeUpdate": + executeUpdateCalls.incrementAndGet(); + throw new AssertionError("executeBatchStatements must not execute statements one by one"); + default: + return defaultValue(method.getReturnType()); + } + }; + return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[]{Statement.class}, handler); + } + + private static Connection connectionProxy(Statement statement) { + InvocationHandler handler = (Object unused, Method method, Object[] args) -> { + if ("createStatement".equals(method.getName())) { + return statement; + } + return defaultValue(method.getReturnType()); + }; + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, handler); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) { + return false; + } + if (type == Byte.TYPE) { + return (byte) 0; + } + if (type == Short.TYPE) { + return (short) 0; + } + if (type == Integer.TYPE) { + return 0; + } + if (type == Long.TYPE) { + return 0L; + } + if (type == Float.TYPE) { + return 0f; + } + if (type == Double.TYPE) { + return 0.0d; + } + if (type == Character.TYPE) { + return '\0'; + } + return null; + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java new file mode 100644 index 0000000..c2bc612 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java @@ -0,0 +1,764 @@ +package com.dbx.agent; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CommonJavaCompatibilityTest { + @Test + void definesSharedAgentProtocolContract() { + assertEquals("handshake", AgentProtocol.METHOD_HANDSHAKE); + assertEquals(1, AgentProtocol.PROTOCOL_VERSION); + assertTrue(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_CONNECT)); + assertTrue(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_QUERY)); + assertTrue(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_METADATA)); + } + + @Test + void agentProtocolMatchesContractResource() { + JsonObject contract = protocolContract(); + + assertEquals(AgentProtocol.PROTOCOL_VERSION, contract.get("protocolVersion").getAsInt()); + assertEquals(AgentProtocol.METHOD_HANDSHAKE, contract.get("handshakeMethod").getAsString()); + assertEquals( + Arrays.asList("protocolVersion", "agentProtocolVersion", "capabilities"), + strings(contract.getAsJsonArray("handshakeResponseFields")) + ); + assertEquals(AgentProtocol.ALL_CAPABILITIES, strings(contract.getAsJsonArray("allCapabilities"))); + assertEquals(AgentProtocol.CAPABILITIES, strings(contract.getAsJsonArray("capabilities"))); + assertEquals(AgentProtocol.CAPABILITIES, strings(contract.getAsJsonArray("defaultSqlCapabilities"))); + assertEquals(AgentProtocol.COMMON_METHODS, strings(contract.getAsJsonArray("commonMethods"))); + assertEquals(AgentProtocol.MONGO_LEGACY_METHODS, strings(contract.getAsJsonArray("mongoLegacyMethods"))); + assertEquals(AgentProtocol.KV_METHODS, strings(contract.getAsJsonArray("kvMethods"))); + } + + @Test + void jsonRpcServerExposesProtocolHandshake() { + JsonRpcServer server = new JsonRpcServer(new MinimalAgent()); + + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"" + AgentProtocol.METHOD_HANDSHAKE + "\",\"params\":{\"appVersion\":\"0.5.13\",\"supportedProtocolVersions\":[1]}}" + ); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + JsonObject result = json.getAsJsonObject("result"); + assertEquals("2.0", json.get("jsonrpc").getAsString()); + assertEquals(7, json.get("id").getAsInt()); + assertEquals(1, result.get("protocolVersion").getAsInt()); + assertEquals(1, result.get("agentProtocolVersion").getAsInt()); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "connect")); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "query")); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata")); + } + + @Test + void jsonRpcServerSerializesArbitraryPrecisionNumbersAsStrings() { + JsonRpcServer server = new JsonRpcServer(new PreciseNumberAgent()); + + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"" + AgentProtocol.METHOD_EXECUTE_QUERY + "\",\"params\":{\"sql\":\"select n from t\"}}" + ); + + JsonArray row = JsonParser.parseString(response) + .getAsJsonObject() + .getAsJsonObject("result") + .getAsJsonArray("rows") + .get(0) + .getAsJsonArray(); + assertEquals("12345678901234567890.1234", row.get(0).getAsString()); + assertTrue(row.get(0).getAsJsonPrimitive().isString()); + assertEquals("12345678901234567890", row.get(1).getAsString()); + assertTrue(row.get(1).getAsJsonPrimitive().isString()); + assertEquals(42, row.get(2).getAsInt()); + assertTrue(row.get(2).getAsJsonPrimitive().isNumber()); + } + + @Test + void jsonRpcServerReconnectsWhenStoredJdbcConnectionIsStale() { + ReconnectingAgent agent = new ReconnectingAgent(); + JsonRpcServer server = new JsonRpcServer(agent); + + server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"connect\",\"params\":{\"host\":\"db.example.com\",\"port\":1521,\"database\":\"ORCL\",\"username\":\"u\",\"password\":\"p\"}}" + ); + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"list_databases\",\"params\":{}}" + ); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("result")); + assertEquals(2, agent.connectCount); + assertEquals(1, agent.disconnectCount); + assertEquals(1, agent.firstConnectionValidChecks); + } + + @Test + void jsonRpcServerValidatesCurrentJdbcConnection() { + ReconnectingAgent agent = new ReconnectingAgent(); + JsonRpcServer server = new JsonRpcServer(agent); + + server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"connect\",\"params\":{\"host\":\"db.example.com\",\"port\":1521,\"database\":\"ORCL\",\"username\":\"u\",\"password\":\"p\"}}" + ); + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"" + AgentProtocol.METHOD_VALIDATE_CONNECTION + "\",\"params\":{}}" + ); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("error")); + assertEquals(1, agent.connectCount); + assertEquals(1, agent.firstConnectionValidChecks); + } + + @Test + void jsonRpcServerSwitchesCatalogBeforeMetadataCalls() { + CatalogSwitchAgent agent = new CatalogSwitchAgent(); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"" + AgentProtocol.METHOD_LIST_TABLES + "\",\"params\":{\"database\":\"sales\",\"schema\":\"app\"}}" + ); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("result")); + assertEquals("sales", agent.catalogs.get(0)); + assertEquals("app", agent.lastSchema); + } + + @Test + void jsonRpcServerAppliesConstrainedTableMetadataRequests() { + MetadataConstraintAgent agent = new MetadataConstraintAgent(); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"" + AgentProtocol.METHOD_LIST_TABLES + "\",\"params\":{\"schema\":\"app\",\"filter\":\"us\",\"limit\":1,\"offset\":1,\"object_types\":[\"TABLE\"]}}" + ); + + JsonArray result = JsonParser.parseString(response).getAsJsonObject().getAsJsonArray("result"); + assertEquals(1, result.size()); + assertEquals("user_settings", result.get(0).getAsJsonObject().get("name").getAsString()); + assertEquals("app", agent.lastSchema); + } + + @Test + void jsonRpcServerAppliesConstrainedObjectMetadataRequests() { + MetadataConstraintAgent agent = new MetadataConstraintAgent(); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"" + AgentProtocol.METHOD_LIST_OBJECTS + "\",\"params\":{\"schema\":\"app\",\"filter\":\"fn\",\"limit\":1,\"offset\":1,\"object_types\":[\"FUNCTION\"]}}" + ); + + JsonArray result = JsonParser.parseString(response).getAsJsonObject().getAsJsonArray("result"); + assertEquals(1, result.size()); + assertEquals("fetch_name", result.get(0).getAsJsonObject().get("name").getAsString()); + assertEquals("FUNCTION", result.get(0).getAsJsonObject().get("object_type").getAsString()); + } + + @Test + void jsonRpcServerDispatchesTableReadSessionMethods() { + TableReadDispatchAgent agent = new TableReadDispatchAgent(); + JsonRpcServer server = new JsonRpcServer(agent); + + String startResponse = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"" + AgentProtocol.METHOD_START_TABLE_READ + "\",\"params\":{\"sql\":\"select * from orders\",\"schema\":\"public\",\"pageSize\":2,\"fetchSize\":8,\"maxRows\":20,\"timeoutSecs\":3}}" + ); + JsonObject startJson = JsonParser.parseString(startResponse).getAsJsonObject(); + + assertTrue(startJson.has("result")); + assertEquals("select * from orders", agent.lastSql); + assertEquals("public", agent.lastSchema); + assertEquals(new QueryPageOptions(2, 8, 20, 3), agent.lastOptions); + assertEquals("table-session", startJson.getAsJsonObject("result").get("session_id").getAsString()); + + String fetchResponse = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"" + AgentProtocol.METHOD_FETCH_TABLE_READ_PAGE + "\",\"params\":{\"sessionId\":\"table-session\",\"pageSize\":4}}" + ); + JsonObject fetchJson = JsonParser.parseString(fetchResponse).getAsJsonObject(); + + assertTrue(fetchJson.has("result")); + assertEquals("table-session", agent.fetchedSessionId); + assertEquals(4, agent.fetchedPageSize); + assertFalse(fetchJson.getAsJsonObject("result").get("has_more").getAsBoolean()); + + String closeResponse = server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"" + AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION + "\",\"params\":{\"sessionId\":\"table-session\"}}" + ); + JsonObject closeJson = JsonParser.parseString(closeResponse).getAsJsonObject(); + + assertTrue(closeJson.get("result").getAsBoolean()); + assertEquals("table-session", agent.closedSessionId); + } + + + @Test + void exposesJavaFriendlyDefaultsAndModels() { + ConnectParams params = new ConnectParams("localhost", 5432, "demo", "user", "secret", "ssl=false", "", false); + assertEquals("localhost", params.getHost()); + assertEquals("ssl=false", params.getUrl_params()); + + TableInfo table = new TableInfo("orders", "TABLE"); + assertEquals("TABLE", table.getTable_type()); + + QueryResult result = new QueryResult( + Collections.singletonList("id"), + Collections.singletonList(Collections.singletonList(1)), + 2L, + 3L + ); + assertEquals(2L, result.getAffected_rows()); + assertEquals(3L, result.getExecution_time_ms()); + assertFalse(result.getTruncated()); + + QueryPageResult page = new QueryPageResult( + Collections.singletonList("id"), + Collections.emptyList(), + 0L, + 1L, + false, + "session-1", + true + ); + assertEquals("session-1", page.getSession_id()); + assertEquals(true, page.getHas_more()); + + assertEquals(JdbcExecutor.DEFAULT_MAX_ROWS, new ExecuteQueryOptions().getMaxRows()); + assertEquals(100, new QueryPageOptions().getPageSize()); + assertNotNull(JdbcExecutor.INSTANCE); + } + + @Test + void exposesDatabaseAgentDefaultMethodsToJavaImplementors() { + DatabaseAgent agent = new MinimalAgent(); + + assertEquals(1, agent.listObjects("public").size()); + assertThrows(UnsupportedOperationException.class, () -> + agent.getObjectSource("public", "orders", "TABLE") + ); + assertEquals("SET SCHEMA \"public\"", agent.setSchemaSQL("public")); + assertThrows(IllegalStateException.class, () -> + agent.executeQueryPage("select 1", "public") + ); + assertThrows(IllegalStateException.class, () -> + agent.startTableRead("select 1", "public", new QueryPageOptions()) + ); + assertEquals(0L, agent.executeQuery("select 1", "public").getAffected_rows()); + + String ddl = DatabaseAgent.buildTableDdl( + "public", + "orders", + Collections.singletonList(new ColumnInfo("id", "integer", false, null, true)), + Collections.singletonList(new IndexInfo("orders_name_idx", Collections.singletonList("name"), false, false)), + Collections.singletonList(new ForeignKeyInfo("orders_customer_fk", "customer_id", "customers", "id")) + ); + assertEquals( + "CREATE TABLE \"public\".\"orders\" (\n" + + " \"id\" integer NOT NULL,\n" + + " PRIMARY KEY (\"id\"),\n" + + " CONSTRAINT \"orders_customer_fk\" FOREIGN KEY (\"customer_id\") REFERENCES \"customers\"(\"id\")\n" + + ");\n\n" + + "CREATE INDEX \"orders_name_idx\" ON \"public\".\"orders\" (\"name\");", + ddl + ); + } + + @Test + void databaseAgentDefaultConstraintsFilterLegacyMetadataOverrides() { + DatabaseAgent agent = new LegacyObjectTypeAgent(); + + List tables = agent.listTables( + "public", + new MetadataListConstraints("us", 1, 1, Collections.singletonList("TABLE")) + ); + assertEquals(1, tables.size()); + assertEquals("user_settings", tables.get(0).getName()); + + List objects = agent.listObjects( + "public", + new MetadataListConstraints("us", 1, 0, Collections.singletonList("VIEW")) + ); + assertEquals(1, objects.size()); + assertEquals("usage_view", objects.get(0).getName()); + } + + @Test + void metadataConstraintsTreatTdengineStableAsTable() { + MetadataListConstraints constraints = + new MetadataListConstraints(null, null, null, Collections.singletonList("TABLE")); + TableInfo stable = new TableInfo("meters", "STABLE", null, null, null); + + List tables = constraints.filterTables(Collections.singletonList(stable)); + + assertEquals(Collections.singletonList(stable), tables); + } + + @Test + void metadataConstraintsMatchTableAndObjectComments() { + MetadataListConstraints tableConstraints = + new MetadataListConstraints("account", null, null, Collections.singletonList("TABLE")); + List tables = tableConstraints.filterTables(Arrays.asList( + new TableInfo("orders", "TABLE", "sales archive"), + new TableInfo("profile", "TABLE", "customer account data"), + new TableInfo("account_view", "VIEW", "ignored by type") + )); + assertEquals(1, tables.size()); + assertEquals("profile", tables.get(0).getName()); + + MetadataListConstraints objectConstraints = + new MetadataListConstraints("revenue", null, null, Collections.singletonList("VIEW")); + List objects = objectConstraints.filterObjects(Arrays.asList( + new ObjectInfo("order_view", "VIEW", "public", "monthly revenue summary"), + new ObjectInfo("sync_user", "PROCEDURE", "public", "sync revenue data"), + new ObjectInfo("audit_log", "TABLE", "public", "audit records") + )); + assertEquals(1, objects.size()); + assertEquals("order_view", objects.get(0).getName()); + } + + @Test + void executesTransactionsOneByOneWhenJdbcDriverDoesNotSupportTransactions() { + List calls = new ArrayList<>(); + DatabaseAgent agent = new TransactionAgent(nonTransactionalConnection(calls)); + + QueryResult result = agent.executeTransaction(Arrays.asList("UPDATE A SET ID = 1", "UPDATE B SET ID = 2"), "APP"); + + assertEquals(2L, result.getAffected_rows()); + assertEquals( + Arrays.asList("supportsTransactions", "execute:SET SCHEMA \"APP\"", "executeUpdate:UPDATE A SET ID = 1", "executeUpdate:UPDATE B SET ID = 2"), + calls + ); + } + + @Test + void buildsTableDdlWithoutSchemaQualifierWhenSchemaIsBlank() { + String ddl = DatabaseAgent.buildTableDdl( + "", + "orders", + Collections.singletonList(new ColumnInfo("id", "integer", false, null, true)), + Collections.emptyList(), + Collections.emptyList() + ); + + assertEquals( + "CREATE TABLE \"orders\" (\n" + + " \"id\" integer NOT NULL,\n" + + " PRIMARY KEY (\"id\")\n" + + ");\n", + ddl + ); + } + + @Test + void buildsTableDdlWithColumnComments() { + String ddl = DdlBuilder.buildTableDdl( + "public", + "orders", + Collections.singletonList(new ColumnInfo( + "display_name", + "varchar", + true, + null, + false, + null, + "User's display name", + null, + null, + 64 + )), + Collections.emptyList(), + Collections.emptyList(), + false, + true + ); + + assertEquals( + "CREATE TABLE \"public\".\"orders\" (\n" + + " \"display_name\" varchar(64)\n" + + ");\n\n" + + "COMMENT ON COLUMN \"public\".\"orders\".\"display_name\" IS 'User''s display name';", + ddl + ); + } + + private static class MinimalAgent implements DatabaseAgent { + @Override + public void connect(ConnectParams params) { + } + + @Override + public boolean testConnection(ConnectParams params) { + return true; + } + + @Override + public List listDatabases() { + return Collections.emptyList(); + } + + @Override + public List listSchemas() { + return Collections.singletonList("public"); + } + + @Override + public List listTables(String schema) { + return Collections.singletonList(new TableInfo("orders", "TABLE")); + } + + @Override + public List getColumns(String schema, String table) { + return Arrays.asList( + new ColumnInfo("id", "integer", false, null, true), + new ColumnInfo("name", "character varying", true, null, false, null, null, null, null, 255) + ); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return new QueryResult(Collections.emptyList(), Collections.emptyList(), 0L, 0L); + } + + @Override + public void disconnect() { + } + + @Override + public Connection getConnection() { + return null; + } + } + + private static final class TransactionAgent extends MinimalAgent { + private final Connection connection; + + private TransactionAgent(Connection connection) { + this.connection = connection; + } + + @Override + public Connection getConnection() { + return connection; + } + } + + private static final class LegacyObjectTypeAgent extends MinimalAgent { + @Override + public List listTables(String schema) { + return Arrays.asList( + new TableInfo("orders", "TABLE"), + new TableInfo("usage_view", "VIEW"), + new TableInfo("users", "TABLE"), + new TableInfo("user_settings", "TABLE") + ); + } + + @Override + public List listTables(String schema, List objectTypes) { + List result = listTables(schema); + if (objectTypes == null || objectTypes.isEmpty()) { + return result; + } + List filtered = new ArrayList<>(); + for (TableInfo table : result) { + if (objectTypes.contains(table.getTable_type())) { + filtered.add(table); + } + } + return filtered; + } + } + + private static final class PreciseNumberAgent extends MinimalAgent { + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return new QueryResult( + Arrays.asList("decimal_value", "integer_value", "safe_int"), + Collections.singletonList(Arrays.asList( + new BigDecimal("12345678901234567890.1234"), + new BigInteger("12345678901234567890"), + 42 + )), + 0L, + 0L + ); + } + } + + private static final class ReconnectingAgent extends MinimalAgent { + private int connectCount; + private int disconnectCount; + private int firstConnectionValidChecks; + private Connection connection; + + @Override + public void connect(ConnectParams params) { + connectCount += 1; + final int connectionNumber = connectCount; + connection = proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("isClosed".equals(name)) { + return false; + } + if ("isValid".equals(name)) { + if (connectionNumber == 1) { + firstConnectionValidChecks += 1; + return false; + } + return true; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + @Override + public void disconnect() { + disconnectCount += 1; + } + + @Override + public Connection getConnection() { + return connection; + } + } + + private static final class CatalogSwitchAgent extends MinimalAgent { + private final List catalogs = new ArrayList<>(); + private String lastSchema = ""; + + @Override + public Connection getConnection() { + return proxy(Connection.class, (method, args) -> { + if ("setCatalog".equals(method.getName())) { + catalogs.add(String.valueOf(args[0])); + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + @Override + public List listTables(String schema) { + lastSchema = schema; + return super.listTables(schema); + } + } + + private static final class MetadataConstraintAgent extends MinimalAgent { + private String lastSchema = ""; + + @Override + public Connection getConnection() { + return proxy(Connection.class, (method, args) -> { + if ("isClosed".equals(method.getName())) { + return false; + } + if ("isValid".equals(method.getName())) { + return true; + } + return defaultValue(method.getReturnType()); + }); + } + + @Override + public List listTables(String schema) { + lastSchema = schema; + return Arrays.asList( + new TableInfo("orders", "TABLE"), + new TableInfo("users", "TABLE"), + new TableInfo("usage_view", "VIEW"), + new TableInfo("user_settings", "TABLE") + ); + } + + @Override + public List listObjects(String schema) { + return Arrays.asList( + new ObjectInfo("orders", "TABLE", schema, null), + new ObjectInfo("find_user", "FUNCTION", schema, null), + new ObjectInfo("fetch_name", "FUNCTION", schema, null), + new ObjectInfo("cleanup_user", "PROCEDURE", schema, null) + ); + } + } + + private static final class TableReadDispatchAgent extends MinimalAgent { + private String lastSql; + private String lastSchema; + private QueryPageOptions lastOptions; + private String fetchedSessionId; + private int fetchedPageSize; + private String closedSessionId; + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + lastSql = sql; + lastSchema = schema; + lastOptions = options; + return new QueryPageResult( + Collections.singletonList("id"), + Collections.singletonList(Collections.singletonList(1)), + 0L, + 5L, + false, + "table-session", + true + ); + } + + @Override + public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) { + fetchedSessionId = sessionId; + fetchedPageSize = pageSize; + return new QueryPageResult( + Collections.singletonList("id"), + Collections.singletonList(Collections.singletonList(2)), + 0L, + 0L, + false, + null, + false + ); + } + + @Override + public boolean closeTableReadSession(String sessionId) { + closedSessionId = sessionId; + return "table-session".equals(sessionId); + } + } + + private static Connection nonTransactionalConnection(List calls) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("getMetaData".equals(name)) { + return proxy(java.sql.DatabaseMetaData.class, (metaMethod, metaArgs) -> { + if ("supportsTransactions".equals(metaMethod.getName())) { + calls.add("supportsTransactions"); + return false; + } + return defaultValue(metaMethod.getReturnType()); + }); + } + if ("createStatement".equals(name)) { + return proxy(java.sql.Statement.class, (stmtMethod, stmtArgs) -> { + if ("execute".equals(stmtMethod.getName())) { + calls.add("execute:" + stmtArgs[0]); + return false; + } + if ("executeUpdate".equals(stmtMethod.getName())) { + calls.add("executeUpdate:" + stmtArgs[0]); + return 1; + } + return defaultValue(stmtMethod.getReturnType()); + }); + } + if ("setSchema".equals(name)) { + calls.add("setSchema:" + args[0]); + return null; + } + if ("setCatalog".equals(name)) { + calls.add("setCatalog:" + args[0]); + return null; + } + if ("setAutoCommit".equals(name) || "commit".equals(name) || "rollback".equals(name)) { + calls.add(name); + return null; + } + if ("getAutoCommit".equals(name)) { + return true; + } + return defaultValue(method.getReturnType()); + }); + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + return null; + } + + private static boolean containsCapability(JsonArray capabilities, String expected) { + for (int i = 0; i < capabilities.size(); i++) { + if (expected.equals(capabilities.get(i).getAsString())) { + return true; + } + } + return false; + } + + private static JsonObject protocolContract() { + InputStream stream = CommonJavaCompatibilityTest.class.getResourceAsStream("/agent-protocol-v1.json"); + if (stream == null) { + throw new AssertionError("agent-protocol-v1.json resource missing"); + } + return JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private static List strings(JsonArray array) { + List result = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + result.add(array.get(i).getAsString()); + } + return result; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/ConfiguredJdbcAgentTest.java b/agents/common/src/test/java/com/dbx/agent/ConfiguredJdbcAgentTest.java new file mode 100644 index 0000000..7a24343 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/ConfiguredJdbcAgentTest.java @@ -0,0 +1,91 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ConfiguredJdbcAgentTest { + @Test + void buildsJdbcUrlFromProfileTemplateAndUrlParams() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "com.example.Driver", + "jdbc:example://{host}:{port}/{database}" + ); + + String url = profile.buildUrl( + new ConnectParams( + "127.0.0.1", + 1234, + "demo", + "", + "", + "ssl=false", + "", + false + ) + ); + + assertEquals("jdbc:example://127.0.0.1:1234/demo?ssl=false", url); + } + + @Test + void usesExplicitConnectionStringBeforeTemplate() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "com.example.Driver", + "jdbc:example://{host}:{port}/{database}" + ); + + String url = profile.buildUrl( + new ConnectParams( + "127.0.0.1", + 1234, + "demo", + "", + "", + "", + "jdbc:example://server:4321/prod", + false + ) + ); + + assertEquals("jdbc:example://server:4321/prod", url); + } + + @Test + void usesDefaultPortWhenParamsOmitPort() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "com.example.Driver", + "jdbc:example://{host}:{port}/{database}", + 9999 + ); + + String url = profile.buildUrl( + new ConnectParams( + "127.0.0.1", + 0, + "demo", + "", + "", + "", + "", + false + ) + ); + + assertEquals("jdbc:example://127.0.0.1:9999/demo", url); + } + + @Test + void canDisableSchemaSwitchingForDriversThatDoNotSupportContextChanges() { + ConfiguredJdbcAgent agent = new ConfiguredJdbcAgent( + new JdbcAgentProfile( + "com.example.Driver", + "jdbc:example://{host}:{port}/{database}", + 0, + true + ) + ) {}; + + assertEquals("", agent.setSchemaSQL("APP")); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java b/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java new file mode 100644 index 0000000..3be5431 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/EwkbWktDecoderTest.java @@ -0,0 +1,163 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EwkbWktDecoderTest { + + private static byte[] hex(String s) { + s = s.replace(" ", ""); + if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2); + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + // ────────────────────────────────────────────────────────────────────── + // Cross-checked against the Rust ewkb_to_wkt tests in + // crates/dbx-core/src/db/postgres.rs (same hex inputs, same expected WKT). + // ────────────────────────────────────────────────────────────────────── + + @Test + void decodesPointWithSridFromRustFixture() { + byte[] raw = hex("0101000020E6100000C520B07268195D404E62105839F44340"); + assertEquals("POINT(116.397 39.908)", EwkbWktDecoder.decode(raw)); + } + + @Test + void decodesMultiPolygonFromRustFixture() { + byte[] raw = hex( + "0106000020E610000002000000010300000001000000050000000000000000005D40000000000000444000000000" + + "00405D4000000000000044400000000000405D4000000000008044400000000000005D40000000000080444000" + + "00000000005D400000000000004440010300000001000000050000000000000000805D40000000000080434000" + + "00000000C05D4000000000008043400000000000C05D4000000000000044400000000000805D40000000000000" + + "44400000000000805D400000000000804340" + ); + assertEquals( + "MULTIPOLYGON(((116 40,117 40,117 41,116 41,116 40)),((118 39,119 39,119 40,118 40,118 39)))", + EwkbWktDecoder.decode(raw) + ); + } + + @Test + void decodesGeometryCollectionFromRustFixture() { + byte[] raw = hex( + "0107000020E61000000200000001010000000000000000005D40000000000000444001020000000200000000000000" + + "00405D4000000000008044400000000000805D400000000000004540" + ); + assertEquals( + "GEOMETRYCOLLECTION(POINT(116 40),LINESTRING(117 41,118 42))", + EwkbWktDecoder.decode(raw) + ); + } + + @Test + void decodesHexStringWithoutPrefix() { + String hex = "0101000020E6100000C520B07268195D404E62105839F44340"; + assertEquals("POINT(116.397 39.908)", EwkbWktDecoder.decode(hex)); + } + + @Test + void decodesHexStringWith0xPrefix() { + String hex = "0x0101000020E6100000C520B07268195D404E62105839F44340"; + assertEquals("POINT(116.397 39.908)", EwkbWktDecoder.decode(hex)); + } + + @Test + void preservesAlreadyWktTextFromStAsText() { + String wkt = "POINT(116.397 39.908)"; + assertEquals(wkt, EwkbWktDecoder.decode(wkt)); + } + + @Test + void preservesAlreadyEwktTextWithSrid() { + String ewkt = "SRID=4326;POINT(116.397 39.908)"; + assertEquals(ewkt, EwkbWktDecoder.decode(ewkt)); + } + + @Test + void truncatedBytesFallBackToHex() { + // First few bytes of a valid point but truncated mid-coordinate. + byte[] raw = hex("0101000020E6100000C520B072"); + Object decoded = EwkbWktDecoder.decode(raw); + assertTrue(decoded instanceof String); + String s = (String) decoded; + assertTrue(s.startsWith("0x"), "expected hex fallback, got: " + s); + assertEquals("0x0101000020e6100000c520b072", s); + } + + @Test + void unparseableHexStringPreservedWithHexPrefix() { + // Looks like hex (even, leading 01), but bytes are not valid EWKB. + String mostly = "0123456789ABCDEF"; + Object decoded = EwkbWktDecoder.decode(mostly); + assertTrue(decoded instanceof String); + // Shape A: parseEwkb returns null, so we re-emit the input ensuring 0x prefix. + assertTrue(((String) decoded).toLowerCase().startsWith("0x"), decoded.toString()); + } + + @Test + void nullInputReturnsNull() { + assertNull(EwkbWktDecoder.decode(null)); + } + + @Test + void formatsWholeNumberDoublesWithoutTrailingDot() { + // Ensure POINT(116 40) is "116 40", not "116.0 40.0" — matches Rust f64 Display. + byte[] raw = hex("0101000020E61000000000000000005D400000000000004440"); + assertEquals("POINT(116 40)", EwkbWktDecoder.decode(raw)); + } + + @Test + void emptyPointBecomesPointEmpty() { + // A 2D point with both coordinates = NaN encodes as "POINT EMPTY" in the decoder. + // EWKB: little-endian Point with SRID=0 omitted; type=1, then two NaN doubles. + byte[] raw = hex("0101000000" + nanLe() + nanLe()); + assertEquals("POINT EMPTY", EwkbWktDecoder.decode(raw)); + } + + private static String nanLe() { + // IEEE 754 quiet NaN, little-endian — same value Rust's f64::NAN encodes to. + long bits = Double.doubleToRawLongBits(Double.NaN); + StringBuilder sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) { + int b = (int) ((bits >>> (i * 8)) & 0xFF); + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + @Test + void pgGeometryReflectiveDecodeReturnsValueText() { + // Synthetic stand-in for org.postgis.PGgeometry — same shape: getValue() returns EWKT. + Object pgGeom = new FakePGgeometry("SRID=4326;POINT(116.397 39.908)"); + assertEquals("SRID=4326;POINT(116.397 39.908)", EwkbWktDecoder.decode(pgGeom)); + } + + @SuppressWarnings("unused") + public static final class FakePGgeometry { + private final String value; + + public FakePGgeometry(String value) { + this.value = value; + } + + // Class name won't match the reflective list so this falls through to toString(); + // we keep it here purely to assert the toString() fallback also works (no NPE). + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/JdbcAgentProfileExtendedTest.java b/agents/common/src/test/java/com/dbx/agent/JdbcAgentProfileExtendedTest.java new file mode 100644 index 0000000..e8848f2 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/JdbcAgentProfileExtendedTest.java @@ -0,0 +1,109 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JdbcAgentProfileExtendedTest { + @Test + void appendsQueryParamsToJdbcUrlTemplates() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:example://{host}:{port}/{database}", + 1234 + ); + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(0); + params.setDatabase("analytics"); + params.setUrl_params("ssl=true"); + + assertEquals("jdbc:example://db.example.com:1234/analytics?ssl=true", profile.buildUrl(params)); + } + + @Test + void appendsSemicolonParamsToSemicolonJdbcUrlTemplates() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:exa:{host}:{port};schema={database}", + 8563 + ); + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(0); + params.setDatabase("analytics"); + params.setUrl_params("encryption=1"); + + assertEquals("jdbc:exa:db.example.com:8563;schema=analytics;encryption=1", profile.buildUrl(params)); + } + + @Test + void appendsCommaParamsToTeradataJdbcUrlTemplates() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:teradata://{host}/DBS_PORT={port},DATABASE={database}", + 1025 + ); + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(0); + params.setDatabase("analytics"); + params.setUrl_params("LOGMECH=LDAP"); + + assertEquals("jdbc:teradata://db.example.com/DBS_PORT=1025,DATABASE=analytics,LOGMECH=LDAP", profile.buildUrl(params)); + } + + @Test + void appendsColonPropertiesToDb2JdbcUrlTemplates() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:db2://{host}:{port}/{database}", + 50000 + ); + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(0); + params.setDatabase("analytics"); + params.setUrl_params("sslConnection=true"); + + assertEquals("jdbc:db2://db.example.com:50000/analytics:sslConnection=true;", profile.buildUrl(params)); + } + + @Test + void preservesExplicitConnectionStringForVendorSpecificUrls() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:example://{host}:{port}/{database}", + 1234 + ); + ConnectParams params = new ConnectParams(); + params.setConnection_string("jdbc:example:special://host/path;token=secret"); + + assertEquals("jdbc:example:special://host/path;token=secret", profile.buildUrl(params)); + } + + @Test + void exposesDialectProfileOptions() { + JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:example://{host}:{port}/{database}", + 1234, + false, + java.util.Collections.singleton("SYS"), + java.util.Arrays.asList("TABLE", "VIEW"), + "`", + "USE", + false, + true, + true, + true + ); + + assertEquals("USE `analytics``prod`", profile.schemaSwitchSql("analytics`prod")); + assertEquals("`orders`", profile.quoteIdentifier("orders")); + assertEquals(false, profile.getCatalogFallbackEnabled()); + assertEquals(true, profile.getNativeTableDdlSupported()); + assertEquals(true, profile.getObjectSourceSupported()); + assertEquals(true, profile.getTriggersSupported()); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java b/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java new file mode 100644 index 0000000..6d54dfc --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java @@ -0,0 +1,265 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.rowset.serial.SerialBlob; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JdbcExecutorTest { + @Test + void stringResultValueFormatsBlobWithoutUsingStringConversion() throws Exception { + ResultSet rs = resultSet( + new byte[]{0x01, 0x2A, (byte) 0xFF}, + () -> { + throw new AssertionError("BLOB columns should not be read with getString"); + } + ); + + assertEquals("0x012aff", JdbcExecutor.stringResultValue(rs, 1, Types.BLOB)); + } + + @Test + void defaultResultValueReadsClobColumnsAsText() throws Exception { + ResultSet rs = resultSet(null, () -> "hello clob"); + + assertEquals("hello clob", JdbcExecutor.INSTANCE.defaultResultValue(rs, 1, Types.CLOB)); + } + + @Test + void defaultResultValueNormalizesBlobObjectsFromFallbackTypes() throws Exception { + ResultSet rs = resultSet(new SerialBlob(new byte[]{0x0A, 0x0B}), null, false); + + assertEquals("0x0a0b", JdbcExecutor.INSTANCE.defaultResultValue(rs, 1, Types.OTHER)); + } + + @Test + void readResultSetCachesColumnTypeMetadataAcrossRows() { + CountingResultSetFixture fixture = countingResultSet(new Object[][]{ + {1, "Ada"}, + {2, "Grace"} + }); + + QueryResult result = JdbcExecutor.INSTANCE.readResultSet( + fixture.resultSet(), + 12L, + 10, + JdbcExecutor.INSTANCE::defaultResultValue + ); + + assertEquals(Arrays.asList("id", "name"), result.getColumns()); + assertEquals(Arrays.asList("INTEGER", "VARCHAR"), result.getColumn_types()); + assertEquals(Arrays.asList(Arrays.asList(1, "Ada"), Arrays.asList(2, "Grace")), result.getRows()); + assertEquals(1, fixture.getMetaDataCalls()); + assertEquals(2, fixture.getColumnTypeCalls()); + } + + @Test + void schemaSwitcherPrefersDriverSpecificSql() throws Exception { + List calls = new ArrayList<>(); + + JdbcSchemaSwitcher.apply(schemaConnection(calls, false), "APP", schema -> "USE " + schema); + + assertEquals(Arrays.asList("execute:USE APP"), calls); + } + + @Test + void schemaSwitcherFallsBackToSetSchemaWhenSqlFails() throws Exception { + List calls = new ArrayList<>(); + + JdbcSchemaSwitcher.apply(schemaConnection(calls, true), "APP", schema -> "USE " + schema); + + assertEquals(Arrays.asList("execute:USE APP", "setSchema:APP"), calls); + } + + private static ResultSet resultSet(byte[] bytes, StringSupplier stringSupplier) { + return resultSet(bytes, stringSupplier, false); + } + + private static Connection schemaConnection(List calls, boolean failSchemaSql) { + InvocationHandler handler = (Object unused, Method method, Object[] args) -> { + switch (method.getName()) { + case "createStatement": + return schemaStatement(calls, failSchemaSql); + case "setSchema": + calls.add("setSchema:" + args[0]); + return null; + default: + return defaultValue(method.getReturnType()); + } + }; + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, handler); + } + + private static Statement schemaStatement(List calls, boolean failSchemaSql) { + InvocationHandler handler = (Object unused, Method method, Object[] args) -> { + switch (method.getName()) { + case "execute": + calls.add("execute:" + args[0]); + if (failSchemaSql) { + throw new SQLException("unsupported schema SQL"); + } + return false; + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }; + return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[]{Statement.class}, handler); + } + + private static CountingResultSetFixture countingResultSet(Object[][] rows) { + String[] labels = {"id", "name"}; + int[] sqlTypes = {Types.INTEGER, Types.VARCHAR}; + String[] typeNames = {"INTEGER", "VARCHAR"}; + AtomicInteger cursor = new AtomicInteger(-1); + AtomicInteger getMetaDataCalls = new AtomicInteger(); + AtomicInteger getColumnTypeCalls = new AtomicInteger(); + + InvocationHandler metaHandler = (Object unused, Method method, Object[] args) -> { + switch (method.getName()) { + case "getColumnCount": + return labels.length; + case "getColumnLabel": + return labels[(Integer) args[0] - 1]; + case "getColumnType": + getColumnTypeCalls.incrementAndGet(); + return sqlTypes[(Integer) args[0] - 1]; + case "getColumnTypeName": + return typeNames[(Integer) args[0] - 1]; + default: + return defaultValue(method.getReturnType()); + } + }; + ResultSetMetaData metadata = (ResultSetMetaData) Proxy.newProxyInstance( + ResultSetMetaData.class.getClassLoader(), + new Class[]{ResultSetMetaData.class}, + metaHandler + ); + + InvocationHandler resultSetHandler = (Object unused, Method method, Object[] args) -> { + switch (method.getName()) { + case "getMetaData": + getMetaDataCalls.incrementAndGet(); + return metadata; + case "next": + return cursor.incrementAndGet() < rows.length; + case "getInt": + return ((Number) currentCell(rows, cursor.get(), (Integer) args[0])).intValue(); + case "getString": + Object value = currentCell(rows, cursor.get(), (Integer) args[0]); + return value == null ? null : value.toString(); + case "wasNull": + return false; + default: + return defaultValue(method.getReturnType()); + } + }; + ResultSet resultSet = (ResultSet) Proxy.newProxyInstance( + ResultSet.class.getClassLoader(), + new Class[]{ResultSet.class}, + resultSetHandler + ); + return new CountingResultSetFixture(resultSet, getMetaDataCalls, getColumnTypeCalls); + } + + private static Object currentCell(Object[][] rows, int rowIndex, int columnIndex) { + return rows[rowIndex][columnIndex - 1]; + } + + private static ResultSet resultSet(Object objectValue, StringSupplier stringSupplier, boolean wasNull) { + InvocationHandler handler = (Object unused, Method method, Object[] args) -> { + switch (method.getName()) { + case "getObject": + return objectValue; + case "getBytes": + return objectValue instanceof byte[] ? objectValue : null; + case "getString": + return stringSupplier == null ? null : stringSupplier.get(); + case "wasNull": + return wasNull; + default: + return defaultValue(method.getReturnType()); + } + }; + return (ResultSet) Proxy.newProxyInstance( + ResultSet.class.getClassLoader(), + new Class[]{ResultSet.class}, + handler + ); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) { + return false; + } + if (type == Byte.TYPE) { + return (byte) 0; + } + if (type == Short.TYPE) { + return (short) 0; + } + if (type == Integer.TYPE) { + return 0; + } + if (type == Long.TYPE) { + return 0L; + } + if (type == Float.TYPE) { + return 0f; + } + if (type == Double.TYPE) { + return 0.0d; + } + if (type == Character.TYPE) { + return '\0'; + } + return null; + } + + private interface StringSupplier { + String get() throws Exception; + } + + private static final class CountingResultSetFixture { + private final ResultSet resultSet; + private final AtomicInteger getMetaDataCalls; + private final AtomicInteger getColumnTypeCalls; + + private CountingResultSetFixture( + ResultSet resultSet, + AtomicInteger getMetaDataCalls, + AtomicInteger getColumnTypeCalls + ) { + this.resultSet = resultSet; + this.getMetaDataCalls = getMetaDataCalls; + this.getColumnTypeCalls = getColumnTypeCalls; + } + + private ResultSet resultSet() { + return resultSet; + } + + private int getMetaDataCalls() { + return getMetaDataCalls.get(); + } + + private int getColumnTypeCalls() { + return getColumnTypeCalls.get(); + } + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/MetadataConstraintCoverageTest.java b/agents/common/src/test/java/com/dbx/agent/MetadataConstraintCoverageTest.java new file mode 100644 index 0000000..be84980 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/MetadataConstraintCoverageTest.java @@ -0,0 +1,114 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MetadataConstraintCoverageTest { + private static final Pattern JAVA_METADATA_OVERRIDE = Pattern.compile( + "public\\s+List\\s*<\\s*(?:TableInfo|ObjectInfo)\\s*>\\s+list(?:Tables|Objects)\\s*\\(\\s*String\\s+schema" + ); + private static final Set VALID_STRATEGIES = new HashSet<>(Arrays.asList( + "native-pushdown", + "shared-fallback", + "intentional-fallback" + )); + + @Test + void everyCustomMetadataDriverIsListedInCoverageMatrix() throws Exception { + Path agentsRoot = agentsRoot(); + Map coverage = readCoverageMatrix(agentsRoot.resolve("metadata-constraint-coverage.tsv")); + Set discovered = discoverCustomMetadataDrivers(agentsRoot.resolve("drivers")); + + assertEquals(discovered, coverage.keySet()); + } + + @Test + void coverageMatrixUsesKnownStrategiesAndReasons() throws Exception { + Path agentsRoot = agentsRoot(); + Path matrix = agentsRoot.resolve("metadata-constraint-coverage.tsv"); + for (String line : Files.readAllLines(matrix, StandardCharsets.UTF_8)) { + if (line.startsWith("driver\t") || line.trim().isEmpty()) { + continue; + } + String[] parts = line.split("\t", -1); + assertEquals(4, parts.length, line); + assertTrue(VALID_STRATEGIES.contains(parts[1]), line); + assertFalse(parts[3].trim().isEmpty(), line); + } + } + + private static Set discoverCustomMetadataDrivers(Path driversRoot) throws IOException { + Set result = new HashSet<>(); + try (Stream files = Files.walk(driversRoot)) { + files.filter(Files::isRegularFile).forEach(file -> { + String fileName = file.getFileName().toString(); + if (fileName.endsWith(".java") && hasJavaMetadataOverride(file)) { + result.add(driversRoot.relativize(file).getName(0).toString()); + } else if ("main.go".equals(fileName) && hasGoMetadataDispatcher(file)) { + result.add(driversRoot.relativize(file).getName(0).toString()); + } + }); + } + return result; + } + + private static boolean hasJavaMetadataOverride(Path file) { + try { + return JAVA_METADATA_OVERRIDE.matcher(readUtf8(file)).find(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static boolean hasGoMetadataDispatcher(Path file) { + try { + String source = readUtf8(file); + return source.contains("\"list_tables\"") || source.contains("\"list_objects\""); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static Map readCoverageMatrix(Path matrix) throws IOException { + Map result = new HashMap<>(); + for (String line : Files.readAllLines(matrix, StandardCharsets.UTF_8)) { + if (line.startsWith("driver\t") || line.trim().isEmpty()) { + continue; + } + String[] parts = line.split("\t", -1); + result.put(parts[0], parts[1]); + } + return result; + } + + private static String readUtf8(Path file) throws IOException { + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } + + private static Path agentsRoot() { + Path current = Paths.get("").toAbsolutePath(); + while (current != null) { + if (Files.isDirectory(current.resolve("drivers")) && Files.isDirectory(current.resolve("common"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("Unable to locate agents root"); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java b/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java new file mode 100644 index 0000000..f3d4279 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java @@ -0,0 +1,524 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PostgresLikeAgentTest { + @Test + void metadataQueriesUsePgCatalogInsteadOfInformationSchema() { + TestPostgresLikeAgent agent = new TestPostgresLikeAgent(); + agent.connect(new ConnectParams()); + + agent.listSchemas(); + agent.listTables("app"); + agent.listObjects("app"); + agent.getColumns("app", "orders"); + agent.listCheckConstraintsForTest("app", "orders"); + agent.listForeignKeys("app", "orders"); + agent.listTriggers("app", "orders"); + + String sql = String.join("\n", MetadataSqlFake.statements); + + assertFalse(sql.contains("FROM information_schema"), sql); + assertFalse(sql.contains("JOIN information_schema"), sql); + assertTrue(sql.contains("pg_catalog.pg_namespace"), sql); + assertTrue(sql.contains("pg_catalog.pg_class"), sql); + assertTrue(sql.contains("pg_catalog.pg_proc"), sql); + assertTrue(sql.contains("pg_catalog.pg_attribute"), sql); + assertTrue(sql.contains("pg_catalog.pg_constraint"), sql); + assertTrue(sql.contains("pg_catalog.pg_get_constraintdef"), sql); + assertTrue(sql.contains("pg_catalog.pg_trigger"), sql); + assertFalse(sql.contains(" AS key "), sql); + assertFalse(sql.contains(" key."), sql); + } + + @Test + void mapsPgCatalogBooleanNullableValues() { + TestPostgresLikeAgent agent = new TestPostgresLikeAgent(preparedConnection( + resultSet( + new String[]{"column_name"}, + new Object[][]{{"id"}} + ), + resultSet( + new String[]{ + "column_name", + "data_type", + "is_nullable", + "column_default", + "column_comment", + "numeric_precision", + "numeric_scale", + "character_maximum_length" + }, + new Object[][]{ + {"id", "integer", false, null, null, null, null, null}, + {"name", "character varying(255)", true, null, "display name", null, null, 255} + } + ) + )); + agent.connect(new ConnectParams()); + + List columns = agent.getColumns("public", "base_airspace"); + + assertEquals(2, columns.size()); + assertTrue(columns.get(0).getIs_primary_key()); + assertFalse(columns.get(0).getIs_nullable()); + assertEquals("name", columns.get(1).getName()); + assertTrue(columns.get(1).getIs_nullable()); + assertEquals(Integer.valueOf(255), columns.get(1).getCharacter_maximum_length()); + } + + @Test + void tableDdlIncludesNamedCheckConstraints() { + ConstraintDdlAgent agent = new ConstraintDdlAgent(); + + String ddl = agent.getTableDdl("public", "orders"); + + assertTrue( + ddl.contains("CONSTRAINT \"chk_balance_status\" CHECK (status = ANY (ARRAY['PLAN'::text, 'EXECUTION'::text]))"), + ddl + ); + } + + @Test + void tableDdlFallsBackWhenCheckConstraintCatalogIsUnavailable() { + ConstraintDdlAgent agent = new ConstraintDdlAgent(true); + + String ddl = agent.getTableDdl("public", "orders"); + + assertTrue(ddl.startsWith("CREATE TABLE \"public\".\"orders\""), ddl); + assertFalse(ddl.contains("CHECK"), ddl); + } + + @Test + void postgisGeometryTypeNameDetection() { + assertTrue(PostgresLikeAgent.isPostgisGeometryTypeName("geometry")); + assertTrue(PostgresLikeAgent.isPostgisGeometryTypeName("GEOMETRY")); + assertTrue(PostgresLikeAgent.isPostgisGeometryTypeName(" Geography ")); + assertTrue(PostgresLikeAgent.isPostgisGeometryTypeName("public.geometry")); + assertTrue(PostgresLikeAgent.isPostgisGeometryTypeName("geometry(Point,4326)")); + assertFalse(PostgresLikeAgent.isPostgisGeometryTypeName("")); + assertFalse(PostgresLikeAgent.isPostgisGeometryTypeName(null)); + assertFalse(PostgresLikeAgent.isPostgisGeometryTypeName("text")); + assertFalse(PostgresLikeAgent.isPostgisGeometryTypeName("vector")); + } + + @Test + void executeQueryDecodesGeometryColumnsToWktAndReportsColumnTypes() { + TestPostgresLikeAgent agent = new TestPostgresLikeAgent(); + agent.connect(new ConnectParams()); + + QueryResult result = JdbcExecutor.INSTANCE.readResultSet( + GeometryResultSet.create(), + 5L, + JdbcExecutor.DEFAULT_MAX_ROWS, + agent.geometryAwareResolverForTest() + ); + + assertEquals(2, result.getColumns().size()); + assertEquals("id", result.getColumns().get(0)); + assertEquals("geom", result.getColumns().get(1)); + + // column_types is reported via JDBC getColumnTypeName + assertEquals(2, result.getColumn_types().size()); + assertEquals("int4", result.getColumn_types().get(0)); + assertEquals("geometry", result.getColumn_types().get(1)); + + // The geometry cell is decoded to WKT (matches Rust ewkb_to_wkt fixture). + assertEquals(1, result.getRows().size()); + assertEquals(1, result.getRows().get(0).get(0)); + assertEquals("POINT(116.397 39.908)", result.getRows().get(0).get(1)); + } + + private static final class TestPostgresLikeAgent extends PostgresLikeAgent { + private final Connection connection; + + private TestPostgresLikeAgent() { + this(null); + } + + private TestPostgresLikeAgent(Connection connection) { + super(new PostgresLikeAgentProfile( + PostgresLikeAgentTest.class.getName(), + "jdbc:test://{host}:{port}/{database}" + )); + this.connection = connection; + } + + @Override + protected Connection openConnection(ConnectParams params) { + if (connection != null) { + return connection; + } + return MetadataSqlFake.connection(); + } + + JdbcExecutor.ColumnAwareResultValueReader geometryAwareResolverForTest() { + return (rs, index, sqlType, columnTypeName) -> { + if (PostgresLikeAgent.isPostgisGeometryTypeName(columnTypeName)) { + Object raw = rs.getObject(index); + if (rs.wasNull() || raw == null) { + return null; + } + return EwkbWktDecoder.decode(raw); + } + if (sqlType == Types.INTEGER) { + return rs.getInt(index); + } + return rs.getObject(index); + }; + } + + List listCheckConstraintsForTest(String schema, String table) { + return listCheckConstraints(schema, table); + } + } + + private static final class ConstraintDdlAgent extends PostgresLikeAgent { + private final boolean failCheckConstraintLookup; + + private ConstraintDdlAgent() { + this(false); + } + + private ConstraintDdlAgent(boolean failCheckConstraintLookup) { + super(new PostgresLikeAgentProfile( + PostgresLikeAgentTest.class.getName(), + "jdbc:test://{host}:{port}/{database}" + )); + this.failCheckConstraintLookup = failCheckConstraintLookup; + } + + @Override + public List getColumns(String schema, String table) { + return java.util.Arrays.asList( + new ColumnInfo("id", "bigint", false, null, true), + new ColumnInfo("status", "character varying", false, "'PLAN'::character varying", false) + ); + } + + @Override + public List listIndexes(String schema, String table) { + return java.util.Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return java.util.Collections.emptyList(); + } + + @Override + protected List listCheckConstraints(String schema, String table) { + if (failCheckConstraintLookup) { + throw new RuntimeException("pg_get_constraintdef is unavailable"); + } + return java.util.Collections.singletonList(new CheckConstraintInfo( + "chk_balance_status", + "CHECK (status = ANY (ARRAY['PLAN'::text, 'EXECUTION'::text]))" + )); + } + } + + private static Connection preparedConnection(ResultSet... resultSets) { + int[] resultSetIndex = {0}; + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + return proxy(PreparedStatement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("executeQuery".equals(name)) { + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("setString".equals(name) || "close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + if ("isClosed".equals(name)) { + return false; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet resultSet(String[] columns, Object[][] rows) { + int[] index = {-1}; + boolean[] wasNull = {false}; + return proxy(ResultSet.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] < rows.length; + } + if ("getString".equals(name)) { + Object value = columnValue(columns, rows[index[0]], args[0]); + wasNull[0] = value == null; + return value == null ? null : String.valueOf(value); + } + if ("getBoolean".equals(name)) { + Object value = columnValue(columns, rows[index[0]], args[0]); + wasNull[0] = value == null; + if (value instanceof Boolean) { + return value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String text = String.valueOf(value); + return "1".equals(text) + || "t".equalsIgnoreCase(text) + || "true".equalsIgnoreCase(text) + || "yes".equalsIgnoreCase(text); + } + if ("getInt".equals(name)) { + Object value = columnValue(columns, rows[index[0]], args[0]); + wasNull[0] = value == null; + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return value == null ? 0 : Integer.parseInt(String.valueOf(value)); + } + if ("getObject".equals(name)) { + Object value = columnValue(columns, rows[index[0]], args[0]); + wasNull[0] = value == null; + return value; + } + if ("wasNull".equals(name)) { + return wasNull[0]; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static Object columnValue(String[] columns, Object[] row, Object key) { + if (key instanceof Number) { + return row[((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return row[i]; + } + } + return null; + } + + private static T proxy(Class type, final MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private static final class GeometryResultSet { + static ResultSet create() { + ResultSetMetaData meta = (ResultSetMetaData) Proxy.newProxyInstance( + GeometryResultSet.class.getClassLoader(), + new Class[]{ResultSetMetaData.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "getColumnCount": + return 2; + case "getColumnLabel": + case "getColumnName": + return ((Integer) args[0]) == 1 ? "id" : "geom"; + case "getColumnType": + return ((Integer) args[0]) == 1 ? Types.INTEGER : Types.OTHER; + case "getColumnTypeName": + return ((Integer) args[0]) == 1 ? "int4" : "geometry"; + default: + return null; + } + } + ); + + // POINT(116.397 39.908) with SRID=4326, little-endian. + String hex = "0101000020E6100000C520B07268195D404E62105839F44340"; + byte[] geomBytes = parseHex(hex); + + int[] cursor = new int[]{0}; // 0 = before first row + return (ResultSet) Proxy.newProxyInstance( + GeometryResultSet.class.getClassLoader(), + new Class[]{ResultSet.class}, + (proxy, method, args) -> { + String name = method.getName(); + switch (name) { + case "getMetaData": + return meta; + case "next": + cursor[0] += 1; + return cursor[0] == 1; + case "getInt": + return cursor[0] == 1 ? 1 : 0; + case "getObject": + return ((Integer) args[0]) == 2 ? geomBytes : 1; + case "wasNull": + return false; + case "close": + return null; + default: + return defaultPrimitive(method.getReturnType()); + } + } + ); + } + + private static byte[] parseHex(String s) { + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + private static Object defaultPrimitive(Class t) { + if (Boolean.TYPE.equals(t)) return false; + if (Integer.TYPE.equals(t)) return 0; + if (Long.TYPE.equals(t)) return 0L; + if (Double.TYPE.equals(t)) return 0.0; + return null; + } + } + + private static final class MetadataSqlFake { + private static final List statements = new ArrayList(); + + private static Connection connection() { + statements.clear(); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + statements.add((String) args[0]); + return preparedStatement(); + } + if ("isClosed".equals(name)) { + return false; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static PreparedStatement preparedStatement() { + final ResultSet resultSet = emptyResultSet(); + return proxy(PreparedStatement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("executeQuery".equals(name)) { + return resultSet; + } + if ("setString".equals(name)) { + statements.add("param:" + args[0] + "=" + args[1]); + return null; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet emptyResultSet() { + final ResultSetMetaData metadata = proxy(ResultSetMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("getColumnCount".equals(method.getName())) { + return 0; + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(ResultSet.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + return false; + } + if ("getMetaData".equals(name)) { + return metadata; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java b/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java new file mode 100644 index 0000000..1ae5e8d --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/StandardJdbcMetadataTest.java @@ -0,0 +1,776 @@ +package com.dbx.agent; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StandardJdbcMetadataTest { + private final JdbcAgentProfile profile = new JdbcAgentProfile( + "example.Driver", + "jdbc:example://{host}:{port}/{database}", + 0, + false, + Collections.singleton("SYS"), + Arrays.asList("TABLE", "VIEW", "BASE TABLE") + ); + + @Test + void listsSchemasWithFilteringAndStableOrdering() { + Connection conn = connection( + rows(row("TABLE_SCHEM", "APP"), row("TABLE_SCHEM", "SYS"), row("TABLE_SCHEM", "PUBLIC")), + rows(), + rows(), + rows(), + rows(), + rows() + ); + + assertEquals(Arrays.asList("APP", "PUBLIC"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile)); + } + + @Test + void listsSchemasWhenConnectionGetSchemaIsUnsupported() { + Connection conn = connection( + rows(row("TABLE_SCHEM", "APP"), row("TABLE_SCHEM", "PUBLIC")), + rows(), + rows(), + rows(), + rows(), + rows(), + true + ); + + assertEquals(Arrays.asList("APP", "PUBLIC"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile)); + } + + @Test + void listsSchemasWhenConnectionGetSchemaThrowsAbstractMethodError() { + Connection conn = connection( + rows(row("TABLE_SCHEM", "APP"), row("TABLE_SCHEM", "PUBLIC")), + rows(), + rows(), + rows(), + rows(), + rows(), + UnsupportedSchemaCall.ABSTRACT_METHOD_ERROR + ); + + assertEquals(Arrays.asList("APP", "PUBLIC"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile)); + } + + @Test + void listsSchemasWithEmptyResultWhenDriverSchemaMetadataIsUnsupported() { + Connection conn = connection( + rows(), + rows(), + rows(), + rows(), + rows(), + rows(), + UnsupportedSchemaCall.METADATA_AND_CONNECTION_ABSTRACT_METHOD_ERROR + ); + + assertEquals(Collections.emptyList(), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile)); + } + + @Test + void listsTablesWithNormalizedTypesAndStableOrdering() { + Connection conn = connection( + rows(), + rows(row("TABLE_NAME", "ZETA", "TABLE_TYPE", "BASE TABLE", "REMARKS", "last"), + row("TABLE_NAME", "ALPHA", "TABLE_TYPE", "VIEW", "REMARKS", "first")), + rows(), + rows(), + rows(), + rows() + ); + + List tables = StandardJdbcMetadata.INSTANCE.listTables(conn, profile, "", "APP"); + + assertEquals("ALPHA", tables.get(0).getName()); + assertEquals("VIEW", tables.get(0).getTable_type()); + assertEquals("ZETA", tables.get(1).getName()); + assertEquals("TABLE", tables.get(1).getTable_type()); + } + + @Test + void listsProceduresAndFunctionsFromJdbcRoutineMetadata() { + Connection conn = routineConnection( + rows( + row("PROCEDURE_NAME", "PROCESS_ORDER", "REMARKS", "processes an order"), + row("PROCEDURE_NAME", "SHARED_ROUTINE", "REMARKS", null) + ), + rows( + row("FUNCTION_NAME", "CALCULATE_TOTAL", "REMARKS", "calculates a total"), + row("FUNCTION_NAME", "SHARED_ROUTINE", "REMARKS", null) + ) + ); + MetadataListConstraints constraints = + new MetadataListConstraints(null, null, null, Collections.singletonList("PROCEDURE")); + + List objects = StandardJdbcMetadata.INSTANCE.listObjects(conn, profile, "", "APP", constraints); + + assertEquals(2, objects.size()); + assertEquals("PROCESS_ORDER", objects.get(0).getName()); + assertEquals("PROCEDURE", objects.get(0).getObject_type()); + assertEquals("SHARED_ROUTINE", objects.get(1).getName()); + assertEquals("PROCEDURE", objects.get(1).getObject_type()); + } + + @Test + void listsDataTypesFromJdbcTypeInfo() { + Connection conn = connection( + rows(), + rows(), + rows(), + rows(), + rows(), + rows(), + UnsupportedSchemaCall.NONE, + null, + null, + null, + null, + rows(row("TYPE_NAME", "INTEGER"), row("TYPE_NAME", "VARCHAR"), row("TYPE_NAME", "integer")) + ); + + assertEquals(Arrays.asList("INTEGER", "VARCHAR"), StandardJdbcMetadata.INSTANCE.listDataTypes(conn)); + } + + @Test + void listTablesUsesDriverTableTypesWithinProfileAllowList() { + AtomicReference capturedTypes = new AtomicReference<>(); + Connection conn = connection( + rows(), + rows(row("TABLE_NAME", "ORDERS", "TABLE_TYPE", "TABLE", "REMARKS", null)), + rows(), + rows(), + rows(), + rows(), + rows( + row("TABLE_TYPE", "TABLE"), + row("TABLE_TYPE", "LOCAL TEMPORARY"), + row("TABLE_TYPE", "BASE TABLE") + ), + capturedTypes + ); + + StandardJdbcMetadata.INSTANCE.listTables(conn, profile, "", "APP"); + + assertEquals(Arrays.asList("TABLE", "BASE TABLE"), Arrays.asList(capturedTypes.get())); + } + + @Test + void listTablesAllowsHanaColumnAndRowTables() { + JdbcAgentProfile hanaProfile = new JdbcAgentProfile( + "com.sap.db.jdbc.Driver", + "jdbc:sap://{host}:{port}/?databaseName={database}", + 30015, + false, + Collections.emptySet(), + Arrays.asList("COLUMN TABLE", "ROW TABLE", "TABLE", "VIEW") + ); + AtomicReference capturedTypes = new AtomicReference<>(); + Connection conn = connection( + rows(), + rows( + row("TABLE_NAME", "ORDERS", "TABLE_TYPE", "COLUMN TABLE", "REMARKS", null), + row("TABLE_NAME", "AUDIT_LOG", "TABLE_TYPE", "ROW TABLE", "REMARKS", null), + row("TABLE_NAME", "ACTIVE_ORDERS", "TABLE_TYPE", "VIEW", "REMARKS", null) + ), + rows(), + rows(), + rows(), + rows(), + rows( + row("TABLE_TYPE", "COLUMN TABLE"), + row("TABLE_TYPE", "ROW TABLE"), + row("TABLE_TYPE", "VIEW") + ), + capturedTypes + ); + + List tables = StandardJdbcMetadata.INSTANCE.listTables(conn, hanaProfile, "", "APP"); + + assertEquals(Arrays.asList("COLUMN TABLE", "ROW TABLE", "VIEW"), Arrays.asList(capturedTypes.get())); + assertEquals(Arrays.asList("ACTIVE_ORDERS", "AUDIT_LOG", "ORDERS"), Arrays.asList( + tables.get(0).getName(), + tables.get(1).getName(), + tables.get(2).getName() + )); + assertEquals("VIEW", tables.get(0).getTable_type()); + assertEquals("TABLE", tables.get(1).getTable_type()); + assertEquals("TABLE", tables.get(2).getTable_type()); + } + + @Test + void mapsColumnsWithPrimaryKeysAndLengths() { + Connection conn = connection( + rows(), + rows(), + rows(row("COLUMN_NAME", "ID")), + rows(row( + "COLUMN_NAME", "ID", + "TYPE_NAME", "INTEGER", + "NULLABLE", DatabaseMetaData.columnNoNulls, + "COLUMN_DEF", null, + "REMARKS", "identifier", + "COLUMN_SIZE", 10, + "DECIMAL_DIGITS", 0 + ), row( + "COLUMN_NAME", "NAME", + "TYPE_NAME", "VARCHAR", + "NULLABLE", DatabaseMetaData.columnNullable, + "COLUMN_DEF", null, + "REMARKS", null, + "COLUMN_SIZE", 64, + "DECIMAL_DIGITS", 0 + )), + rows(), + rows() + ); + + List columns = StandardJdbcMetadata.INSTANCE.getColumns(conn, profile, "", "APP", "ORDERS"); + + assertEquals("ID", columns.get(0).getName()); + assertTrue(columns.get(0).getIs_primary_key()); + assertFalse(columns.get(0).getIs_nullable()); + assertEquals("NAME", columns.get(1).getName()); + assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length()); + } + + @Test + void marksPrimaryKeysFromConfiguredCatalogWhenColumnsAlreadyLoaded() { + Connection conn = catalogPrimaryKeyFallbackConnection( + "TENANTDB", + rows(), + rows(row("COLUMN_NAME", "ID")), + orderColumns() + ); + + List columns = StandardJdbcMetadata.INSTANCE.getColumns(conn, profile, "TENANTDB", "APP", "ORDERS"); + + assertEquals("ID", columns.get(0).getName()); + assertTrue(columns.get(0).getIs_primary_key()); + assertFalse(columns.get(0).getIs_nullable()); + assertEquals("NAME", columns.get(1).getName()); + assertFalse(columns.get(1).getIs_primary_key()); + assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length()); + } + + @Test + void doesNotMarkPrimaryKeysFromConfiguredCatalogWhenFallbackUnavailable() { + JdbcAgentProfile fallbackDisabledProfile = new JdbcAgentProfile( + "example.Driver", + "jdbc:example://{host}:{port}/{database}", + 0, + false, + Collections.emptySet(), + Collections.singletonList("TABLE"), + "\"", + "SET SCHEMA", + false, + false, + false, + false + ); + Connection disabledConn = catalogPrimaryKeyFallbackConnection( + "TENANTDB", + rows(), + rows(row("COLUMN_NAME", "ID")), + orderColumns() + ); + + List disabledColumns = StandardJdbcMetadata.INSTANCE.getColumns(disabledConn, fallbackDisabledProfile, "TENANTDB", "APP", "ORDERS"); + + assertFalse(disabledColumns.get(0).getIs_primary_key()); + + Connection emptyCatalogConn = catalogPrimaryKeyFallbackConnection( + "TENANTDB", + rows(), + rows(row("COLUMN_NAME", "ID")), + orderColumns() + ); + + List emptyCatalogColumns = StandardJdbcMetadata.INSTANCE.getColumns(emptyCatalogConn, profile, " ", "APP", "ORDERS"); + + assertFalse(emptyCatalogColumns.get(0).getIs_primary_key()); + } + + @Test + void groupsIndexColumnsInOrdinalOrder() { + Connection conn = connection( + rows(), + rows(), + rows(), + rows(), + rows(row("INDEX_NAME", "IDX_ORDERS", "COLUMN_NAME", "B", "ORDINAL_POSITION", (short) 2, "NON_UNIQUE", true), + row("INDEX_NAME", "IDX_ORDERS", "COLUMN_NAME", "A", "ORDINAL_POSITION", (short) 1, "NON_UNIQUE", true)), + rows() + ); + + List indexes = StandardJdbcMetadata.INSTANCE.listIndexes(conn, "APP", "ORDERS"); + + assertEquals(1, indexes.size()); + assertEquals(Arrays.asList("A", "B"), indexes.get(0).getColumns()); + assertFalse(indexes.get(0).getIs_unique()); + } + + @Test + void mapsForeignKeysAndEmptyTriggers() { + Connection conn = connection( + rows(), + rows(), + rows(), + rows(), + rows(), + rows(row("FK_NAME", null, "FKCOLUMN_NAME", "CUSTOMER_ID", "PKTABLE_NAME", "CUSTOMERS", "PKCOLUMN_NAME", "ID")) + ); + + List foreignKeys = StandardJdbcMetadata.INSTANCE.listForeignKeys(conn, "APP", "ORDERS"); + + assertEquals("", foreignKeys.get(0).getName()); + assertEquals("CUSTOMER_ID", foreignKeys.get(0).getColumn()); + assertEquals(Collections.emptyList(), StandardJdbcMetadata.INSTANCE.listTriggers("APP", "ORDERS")); + } + + @Test + void completionAssistantSearchesTablesAndColumnsWithServerSideMasks() { + AtomicReference capturedTableArgs = new AtomicReference<>(); + AtomicReference capturedColumnArgs = new AtomicReference<>(); + Connection conn = connection( + rows(), + rows( + row("TABLE_NAME", "ACCOUNTS", "TABLE_TYPE", "TABLE", "REMARKS", "account table"), + row("TABLE_NAME", "ACCOUNT_VIEW", "TABLE_TYPE", "VIEW", "REMARKS", null) + ), + rows(), + rows(row("COLUMN_NAME", "DISPLAY_NAME", "TYPE_NAME", "VARCHAR", "NULLABLE", DatabaseMetaData.columnNullable, "COLUMN_DEF", null, "REMARKS", "display")), + rows(), + rows(), + UnsupportedSchemaCall.NONE, + rows(row("TABLE_TYPE", "TABLE"), row("TABLE_TYPE", "VIEW")), + null, + capturedTableArgs, + capturedColumnArgs + ); + + CompletionAssistantRequest tablesRequest = request("sales", "APP", "ACC", Arrays.asList(CompletionAssistantObjectKind.TABLE, CompletionAssistantObjectKind.VIEW), null); + CompletionAssistantResponse tables = StandardJdbcMetadata.INSTANCE.completionAssistantSearch(conn, profile, "sales", tablesRequest); + + assertEquals(2, tables.getCandidates().size()); + assertEquals(CompletionAssistantCandidateKind.TABLE, tables.getCandidates().get(0).getKind()); + assertEquals("ACC%", capturedTableArgs.get()[2]); + + CompletionAssistantRequest columnsRequest = request("sales", "APP", "DISPLAY", Collections.singletonList(CompletionAssistantObjectKind.COLUMN), "ACCOUNTS"); + CompletionAssistantResponse columns = StandardJdbcMetadata.INSTANCE.completionAssistantSearch(conn, profile, "sales", columnsRequest); + + assertEquals(1, columns.getCandidates().size()); + assertEquals("DISPLAY_NAME", columns.getCandidates().get(0).getName()); + assertEquals("VARCHAR", columns.getCandidates().get(0).getData_type()); + assertEquals("ACCOUNTS", capturedColumnArgs.get()[2]); + assertEquals("DISPLAY%", capturedColumnArgs.get()[3]); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys + ) { + return connection(schemas, tables, primaryKeys, columns, indexes, foreignKeys, false); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys, + boolean unsupportedGetSchema + ) { + return connection( + schemas, + tables, + primaryKeys, + columns, + indexes, + foreignKeys, + unsupportedGetSchema ? UnsupportedSchemaCall.RUNTIME_EXCEPTION : UnsupportedSchemaCall.NONE, + null, + null + ); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys, + UnsupportedSchemaCall unsupportedSchemaCall + ) { + return connection(schemas, tables, primaryKeys, columns, indexes, foreignKeys, unsupportedSchemaCall, null, null); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys, + ResultSet tableTypes, + AtomicReference capturedTableTypes + ) { + return connection(schemas, tables, primaryKeys, columns, indexes, foreignKeys, UnsupportedSchemaCall.NONE, tableTypes, capturedTableTypes); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys, + UnsupportedSchemaCall unsupportedSchemaCall, + ResultSet tableTypes, + AtomicReference capturedTableTypes + ) { + return connection(schemas, tables, primaryKeys, columns, indexes, foreignKeys, unsupportedSchemaCall, tableTypes, capturedTableTypes, null, null); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys, + UnsupportedSchemaCall unsupportedSchemaCall, + ResultSet tableTypes, + AtomicReference capturedTableTypes, + AtomicReference capturedTableArgs, + AtomicReference capturedColumnArgs + ) { + return connection( + schemas, + tables, + primaryKeys, + columns, + indexes, + foreignKeys, + unsupportedSchemaCall, + tableTypes, + capturedTableTypes, + capturedTableArgs, + capturedColumnArgs, + null + ); + } + + private static Connection connection( + ResultSet schemas, + ResultSet tables, + ResultSet primaryKeys, + ResultSet columns, + ResultSet indexes, + ResultSet foreignKeys, + UnsupportedSchemaCall unsupportedSchemaCall, + ResultSet tableTypes, + AtomicReference capturedTableTypes, + AtomicReference capturedTableArgs, + AtomicReference capturedColumnArgs, + ResultSet dataTypes + ) { + DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("getSchemas".equals(name)) { + if (unsupportedSchemaCall == UnsupportedSchemaCall.METADATA_AND_CONNECTION_ABSTRACT_METHOD_ERROR) { + throw new AbstractMethodError("Unimplemented method: getSchemas()"); + } + return schemas; + } + if ("getTables".equals(name)) { + if (capturedTableArgs != null) { + capturedTableArgs.set(args); + } + if (capturedTableTypes != null && args != null && args.length > 3) { + capturedTableTypes.set((String[]) args[3]); + } + return tables; + } + if ("getTableTypes".equals(name) && tableTypes != null) { + return tableTypes; + } + if ("getTypeInfo".equals(name) && dataTypes != null) { + return dataTypes; + } + if ("getPrimaryKeys".equals(name)) { + return primaryKeys; + } + if ("getColumns".equals(name)) { + if (capturedColumnArgs != null) { + capturedColumnArgs.set(args); + } + return columns; + } + if ("getIndexInfo".equals(name)) { + return indexes; + } + if ("getImportedKeys".equals(name)) { + return foreignKeys; + } + if ("getCatalogs".equals(name)) { + return rows(); + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("getMetaData".equals(method.getName())) { + return meta; + } + if ("getSchema".equals(method.getName())) { + if (unsupportedSchemaCall == UnsupportedSchemaCall.RUNTIME_EXCEPTION) { + throw new RuntimeException("Unimplemented method: getSchema()"); + } + if (unsupportedSchemaCall == UnsupportedSchemaCall.ABSTRACT_METHOD_ERROR + || unsupportedSchemaCall == UnsupportedSchemaCall.METADATA_AND_CONNECTION_ABSTRACT_METHOD_ERROR) { + throw new AbstractMethodError("Unimplemented method: getSchema()"); + } + return null; + } + if ("getCatalog".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static Connection catalogPrimaryKeyFallbackConnection( + String fallbackCatalog, + ResultSet defaultPrimaryKeys, + ResultSet fallbackPrimaryKeys, + ResultSet columns + ) { + DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("getPrimaryKeys".equals(name)) { + return fallbackCatalog.equals(args[0]) ? fallbackPrimaryKeys : defaultPrimaryKeys; + } + if ("getColumns".equals(name)) { + return columns; + } + if ("getCatalogs".equals(name)) { + return rows(); + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("getMetaData".equals(method.getName())) { + return meta; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static Connection routineConnection(ResultSet procedures, ResultSet functions) { + DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("getTables".equals(name) || "getTableTypes".equals(name)) { + return rows(); + } + if ("getProcedures".equals(name)) { + return procedures; + } + if ("getFunctions".equals(name)) { + return functions; + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("getMetaData".equals(method.getName())) { + return meta; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet orderColumns() { + return rows(row( + "COLUMN_NAME", "ID", + "TYPE_NAME", "INTEGER", + "NULLABLE", DatabaseMetaData.columnNoNulls, + "COLUMN_DEF", null, + "REMARKS", "identifier", + "COLUMN_SIZE", 10, + "DECIMAL_DIGITS", 0 + ), row( + "COLUMN_NAME", "NAME", + "TYPE_NAME", "VARCHAR", + "NULLABLE", DatabaseMetaData.columnNullable, + "COLUMN_DEF", null, + "REMARKS", null, + "COLUMN_SIZE", 64, + "DECIMAL_DIGITS", 0 + )); + } + + private static ResultSet rows(Map... rows) { + return proxy(ResultSet.class, new MethodHandler() { + private int index = -1; + + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + index += 1; + return index < rows.length; + } + if ("close".equals(name)) { + return null; + } + Object value = rows[index].get(args[0]); + if ("getString".equals(name)) { + return value == null ? null : String.valueOf(value); + } + if ("getObject".equals(name)) { + return value; + } + if ("getInt".equals(name)) { + return value instanceof Number ? ((Number) value).intValue() : 0; + } + if ("getShort".equals(name)) { + return value instanceof Number ? ((Number) value).shortValue() : 0; + } + if ("getBoolean".equals(name)) { + return value instanceof Boolean && (Boolean) value; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + row.put(String.valueOf(values[i]), values[i + 1]); + } + return row; + } + + private static CompletionAssistantRequest request( + String database, + String schema, + String mask, + List kinds, + String parentName + ) { + CompletionAssistantRequest request = new CompletionAssistantRequest(); + setField(request, "database", database); + setField(request, "schema", schema); + setField(request, "mask", mask); + setField(request, "object_kinds", kinds); + setField(request, "parent_schema", schema); + setField(request, "parent_name", parentName); + setField(request, "max_results", 10); + setField(request, "match_mode", CompletionAssistantMatchMode.PREFIX); + return request; + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static T proxy(Class type, final MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } + + private enum UnsupportedSchemaCall { + NONE, + RUNTIME_EXCEPTION, + ABSTRACT_METHOD_ERROR, + METADATA_AND_CONNECTION_ABSTRACT_METHOD_ERROR + } +} diff --git a/agents/docs/agent-authoring.md b/agents/docs/agent-authoring.md new file mode 100644 index 0000000..5aecf95 --- /dev/null +++ b/agents/docs/agent-authoring.md @@ -0,0 +1,318 @@ +# Agent Authoring Guide + +This guide defines the expected shape of a DBX agent. Treat it as the checklist for adding or reviewing an agent. + +## Agent Contract + +Every agent is a standalone JVM process that: + +- Implements `com.dbx.agent.DatabaseAgent`. +- Prefer extending `com.dbx.agent.ConfiguredJdbcAgent` for standard JDBC agents. +- Extend `com.dbx.agent.AbstractJdbcAgent` when the database needs custom metadata SQL but can still share lifecycle and execution behavior. +- Starts with `new JsonRpcServer(new ()).run()` in its `main` method. +- Talks to DBX over stdin/stdout JSON-RPC 2.0. +- Uses JDBC for database access unless the module is explicitly designed around a non-JDBC protocol. +- Produces one shadow JAR named `dbx-agent-.jar`. + +The public behavior should be consistent across agents even when each database has different SQL dialects. + +## Required Methods + +Each agent must implement these capabilities: + +- `connect(params)`: handled by the shared JDBC foundation for JDBC agents. +- `testConnection(params)`: handled by the shared JDBC foundation for JDBC agents. +- `listDatabases()`: return visible catalogs/databases when the database supports them; otherwise return one sensible default database. +- `listSchemas()`: return schemas in stable order. +- `listTables(schema)`: return table-like objects for the selected schema, with normalized `type` values where possible. +- `getColumns(schema, table)`: return column metadata, nullability, defaults, key flags, numeric precision/scale, character length, and comments when available. +- `listIndexes(schema, table)`: return one `IndexInfo` per index, preserving column order. +- `listForeignKeys(schema, table)`: return outbound foreign keys when available. +- `listTriggers(schema, table)`: return triggers when available; return an empty list if the database has no trigger metadata. +- `executeQuery(sql, schema, options)`: inherited from the shared JDBC foundation unless the agent has a documented custom execution behavior. +- `disconnect()`: inherited from the shared JDBC foundation. +- `getConnection()`: inherited from the shared JDBC foundation. + +Throw `IllegalStateException("Not connected")` when a method requires a connection and none exists. + +## SQL Execution Rules + +Do not classify statements by SQL prefix inside individual agents. + +For most JDBC agents, inherit this from `AbstractJdbcAgent` or `ConfiguredJdbcAgent`. Do not copy the execution method into the agent class. If a custom value reader is required, override `resultValue(...)`: + +```java +@Override +protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> rs.getString(index)); +} +``` + +For simple standard JDBC metadata agents, start with a profile: + +```java +public final class ExampleAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile EXAMPLE_PROFILE = new JdbcAgentProfile( + "com.example.Driver", + "jdbc:example://{host}:{port}/{database}", + 1234 + ); + + public ExampleAgent() { + super(EXAMPLE_PROFILE); + } +} +``` + +`JdbcExecutor` owns these behaviors: + +- Trims a trailing semicolon before execution. +- Handles `BEGIN`, `COMMIT`, and `ROLLBACK`. +- Runs schema switching SQL before the user statement. +- Executes statements with `Statement.execute(...)`. +- Reads `ResultSet` output for any statement type, not only `SELECT`. +- Returns update counts for update statements. +- Caps result rows at `options.maxRows`, defaulting to `JdbcExecutor.DEFAULT_MAX_ROWS`. +- Applies `options.fetchSize` to the JDBC statement when provided. +- Marks `truncated = true` only when more rows exist beyond the cap. + +An agent must not reintroduce local copies of: + +- `QUERY_PREFIXES` +- `MAX_ROWS` +- `executeUpdate(trimmedSql)` in `executeQuery` +- result truncation based on `rows.size >= MAX_ROWS` +- `Class.forName(...)` / `DriverManager.getConnection(...)` lifecycle boilerplate outside shared foundation extension points +- local `disconnect()` copies for JDBC agents +- local `JdbcExecutor.INSTANCE.execute(...)` copies for standard query execution + +## Schema And Identifier Rules + +Override `setSchemaSQL(schema)` for the database dialect, or configure the profile dialect options when standard quoting is enough. + +Use `JdbcIdentifiers` helpers when quoting identifiers: + +```java +@Override +public String setSchemaSQL(String schema) { + return "SET SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); +} +``` + +If the database does not support a schema switching statement, return an empty string: + +```java +@Override +public String setSchemaSQL(String schema) { + return ""; +} +``` + +Never concatenate unquoted user-provided schema names into schema-switching SQL unless the target database requires unquoted identifiers and the value has already been validated. + +For metadata queries, prefer prepared statements for `schema`, `table`, and other user-controlled values. + +## Shared JDBC Foundation + +Use the smallest shared base that matches the database: + +- `ConfiguredJdbcAgent`: standard JDBC lifecycle, execution, and standard `DatabaseMetaData`-based metadata. +- `PostgresLikeAgent`: PostgreSQL-family metadata with shared lifecycle and execution. +- `AbstractJdbcAgent`: custom metadata SQL with shared lifecycle, execution, paging, transactions, result conversion, and DDL fallback. + +If a database cannot yet use the shared foundation, add it to the validation allowlist with a specific reason and tests covering the custom behavior. Remove the allowlist entry when the module migrates. + +## Driver Packaging + +Each module chooses one of two driver modes. + +### Bundled Driver + +Use this when the driver is redistributable from Maven Central or another permitted repository: + +```groovy +dependencies { + implementation 'com.example:example-jdbc:1.2.3' +} +``` + +The root Gradle convention supplies `project(':common')`, `project(':test-support')`, JUnit, Java toolchains, the Shadow plugin, and the `dbx-agent-` archive name for included agent modules. + +No manifest flag is needed. + +### External Driver + +Use this when the driver cannot be redistributed or must be supplied by the user: + +```groovy +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'Example DB', + 'Agent-External-Driver': 'true', + 'Main-Class': 'com.dbx.agent.example.ExampleAgent' + ) + } +} +``` + +The release workflow reads `Agent-External-Driver: true` and emits `external_driver_required: true` in `agent-registry.json`. + +## Module Registration + +When adding an agent named `exampledb`: + +- Create the module under `drivers/exampledb`. +- Add `exampledb` to `driverModules` in `settings.gradle`. +- Add `"exampledb": "0.1.0"` to `versions.json`. +- Set `Agent-Label` to the user-facing database name. +- Set `Main-Class` to the Java agent class, usually `com.dbx.agent.exampledb.ExampledbAgent`. +- Add the database to the README support table. + +The root `build.gradle` convention derives the archive name from the module name, so `exampledb` builds `dbx-agent-exampledb.jar` without per-module archive configuration. + +`versions.json` must contain only modules included in `settings.gradle`, excluding infrastructure modules such as `common` and `test-support`. + +## Runtime Selection + +Default to: + +```groovy +def java8Projects = ['common', 'test-support'] as Set + +subprojects { + java { + toolchain { + languageVersion = JavaLanguageVersion.of(java8Projects.contains(name) ? 8 : 21) + } + } +} +``` + +Most agents use JRE 21. If an agent needs a special runtime, update the root Gradle convention, the release workflow JRE detection logic, and document the reason in the module. + +## Tests + +Every JDBC agent should have at least one execution-path regression test. + +For agents that can run with an embedded or in-memory database, prefer both shared behavior contracts: + +```java +import java.util.List; + +class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest { + @Override + protected DatabaseAgent createConnectedAgent(String databaseName) { + return H2AgentFixtures.createConnectedAgent(databaseName); + } + + @Override + protected String resultSetSql() { + return "CALL 42"; + } + + @Override + protected List expectedResultSetColumns() { + return List.of("42"); + } + + @Override + protected List> expectedResultSetRows() { + return List.of(List.of(42)); + } + + @Override + protected String rowsSql(int rowCount) { + return "SELECT X FROM SYSTEM_RANGE(1, " + rowCount + ")"; + } +} + +class H2MetadataBehaviorTest extends JdbcMetadataBehaviorTest { + @Override + protected DatabaseAgent createConnectedAgent(String databaseName) { + return H2AgentFixtures.createConnectedAgent(databaseName); + } + + @Override + protected List metadataFixtureSql() { + return List.of( + "CREATE TABLE BETA_TABLE (ID INT PRIMARY KEY)", + "CREATE TABLE ALPHA_TABLE (ID INT PRIMARY KEY)", + "CREATE TABLE COLUMN_ORDER_SAMPLE (ID INT PRIMARY KEY, NAME VARCHAR(64), CREATED_AT TIMESTAMP)" + ); + } + + @Override + protected String metadataSchema() { + return "PUBLIC"; + } + + @Override + protected List expectedTablesInOrder() { + return List.of("ALPHA_TABLE", "BETA_TABLE", "COLUMN_ORDER_SAMPLE"); + } + + @Override + protected String metadataColumnsTable() { + return "COLUMN_ORDER_SAMPLE"; + } + + @Override + protected List expectedColumnsInOrder() { + return List.of("ID", "NAME", "CREATED_AT"); + } +} + +final class H2AgentFixtures { + private H2AgentFixtures() { + } + + static DatabaseAgent createConnectedAgent(String databaseName) { + H2Agent agent = new H2Agent(); + agent.connect(new ConnectParams("mem:" + databaseName + ";DB_CLOSE_DELAY=-1")); + return agent; + } +} +``` + +`JdbcExecutionBehaviorTest` verifies non-`SELECT` result set execution, max-row truncation boundaries, and transaction control statements. `JdbcMetadataBehaviorTest` verifies stable metadata ordering. Agents with limited local test infrastructure can adopt the execution contract first and add the metadata contract later. + +For agents that need unavailable commercial or external drivers, use the fake execution contract: + +```java +class ExampleAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new ExampleAgent(); + } + + @Override + protected String resultSetSql() { + return "SHOW TABLES"; + } +} +``` + +`JdbcFakeExecutionBehaviorTest` injects a fake JDBC connection and verifies that `executeQuery` uses `Statement.execute`, reads the returned `ResultSet`, and does not fall back to `executeQuery` or `executeUpdate`. Use `testImplementation project(':test-support')` for this contract. + +## Review Checklist + +Before opening a PR or release: + +- `executeQuery` delegates to `JdbcExecutor.execute`. +- No local SQL-prefix classifier exists. +- No local result row cap exists unless there is a database-specific reason documented in code. +- Metadata methods use prepared statements for user-controlled schema/table inputs. +- `setSchemaSQL` quotes identifiers or returns an empty string. +- `disconnect` closes and clears the connection. +- `testConnection` closes its temporary connection. +- Root `build.gradle` conventions cover Java plugin, toolchain, JUnit, common/test-support dependencies, Shadow plugin, and archive name. +- Module `build.gradle` has correct driver dependencies, `Agent-Label`, `Main-Class`, and external driver flag. +- `settings.gradle`, `versions.json`, and README are updated together. +- At least one execution-path regression test exists. +- `python3 scripts/validate_agents.py` passes. +- `./gradlew test shadowJar --continue` passes. diff --git a/agents/docs/examples/jdbc-agent-template/README.md b/agents/docs/examples/jdbc-agent-template/README.md new file mode 100644 index 0000000..18c8152 --- /dev/null +++ b/agents/docs/examples/jdbc-agent-template/README.md @@ -0,0 +1,20 @@ +# JDBC Agent Template + +Copy this directory when adding a new JDBC-based DBX agent. + +Recommended flow: + +1. Rename `template` paths, package names, class names, and artifact names to the new agent name. +2. Replace the driver class and JDBC URL builder. +3. Implement metadata SQL using the target database system catalogs or `DatabaseMetaData`. +4. Keep `executeQuery` delegated to `JdbcExecutor`. +5. Place the module under `drivers/`, then add it to `settings.gradle`, `versions.json`, and the root README support table. +6. Run `./gradlew ::test ::shadowJar`. + +To verify this template before copying it, run: + +```bash +./gradlew -p docs/examples/jdbc-agent-template clean test shadowJar +``` + +See `docs/agent-authoring.md` for the full checklist. diff --git a/agents/docs/examples/jdbc-agent-template/build.gradle b/agents/docs/examples/jdbc-agent-template/build.gradle new file mode 100644 index 0000000..45bec3a --- /dev/null +++ b/agents/docs/examples/jdbc-agent-template/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'java' + id 'com.gradleup.shadow' +} + +configure([project(':common'), project(':test-support')]) { + if (name == 'test-support') { + apply plugin: 'java-library' + } else { + apply plugin: 'java' + } + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + } + } + + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4' + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } +} + +dependencies { + implementation project(':common') + + // Bundled driver example: + // implementation 'com.example:example-jdbc:1.2.3' + + // External driver example: + // implementation fileTree(dir: 'libs', include: ['*.jar']) + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4' + testImplementation project(':test-support') +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +tasks.named('shadowJar') { + archiveBaseName = 'dbx-agent-template' + archiveClassifier = '' + manifest { + attributes( + 'Agent-Label': 'Template DB', + // Add this only when users must provide the JDBC driver jar: + // 'Agent-External-Driver': 'true', + 'Main-Class': 'com.dbx.agent.template.TemplateAgent' + ) + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/agents/docs/examples/jdbc-agent-template/settings.gradle b/agents/docs/examples/jdbc-agent-template/settings.gradle new file mode 100644 index 0000000..0a2ac70 --- /dev/null +++ b/agents/docs/examples/jdbc-agent-template/settings.gradle @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + plugins { + id 'com.gradleup.shadow' version '9.0.0-beta12' + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = 'jdbc-agent-template' +include 'common', 'test-support' +project(':common').projectDir = file('../../../common') +project(':test-support').projectDir = file('../../../test-support') diff --git a/agents/docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java b/agents/docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java new file mode 100644 index 0000000..bca4d45 --- /dev/null +++ b/agents/docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java @@ -0,0 +1,27 @@ +package com.dbx.agent.template; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; + +public final class TemplateAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile TEMPLATE_PROFILE = new JdbcAgentProfile( + "com.example.jdbc.TemplateDriver", + "jdbc:template://{host}:{port}/{database}", + 1234 + ); + + public TemplateAgent() { + super(TEMPLATE_PROFILE); + } + + @Override + public String setSchemaSQL(String schema) { + return "SET SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + public static void main(String[] args) { + new JsonRpcServer(new TemplateAgent()).run(); + } +} diff --git a/agents/docs/examples/jdbc-agent-template/src/test/java/com/dbx/agent/template/TemplateAgentTest.java b/agents/docs/examples/jdbc-agent-template/src/test/java/com/dbx/agent/template/TemplateAgentTest.java new file mode 100644 index 0000000..d074e67 --- /dev/null +++ b/agents/docs/examples/jdbc-agent-template/src/test/java/com/dbx/agent/template/TemplateAgentTest.java @@ -0,0 +1,23 @@ +package com.dbx.agent.template; + +import com.dbx.agent.test.TestSupport; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.test.JdbcAgentFake; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TemplateAgentTest { + @Test + void executeQueryUsesStatementExecute() { + TemplateAgent agent = new TemplateAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + agent.executeQuery("SHOW TABLES", null, new ExecuteQueryOptions()); + + Assertions.assertEquals( + List.of("setMaxRows:10001", "execute"), + JdbcAgentFake.calls + ); + } +} diff --git a/agents/docs/release-checklist.md b/agents/docs/release-checklist.md new file mode 100644 index 0000000..f84b33a --- /dev/null +++ b/agents/docs/release-checklist.md @@ -0,0 +1,193 @@ +# Release Checklist + +Use this checklist before pushing release tags or publishing DBX agent jars. + +## 1. Confirm Scope + +- Review the recent commits: + +```bash +git log --oneline -10 +``` + +- Confirm the working tree is clean before tagging: + +```bash +git status --short +``` + +- Check that each intended agent change touches the right module directory, `common`, `test-support`, docs, or workflows. +- Avoid mixing unrelated driver updates, behavior changes, and release-only edits in the same commit. + +## 2. Local Verification + +On this workspace, use the local JDKs under `/private/tmp`: + +```bash +env JAVA_HOME=/private/tmp/dbx-jdk/jdk-21.0.11+10/Contents/Home \ + ./gradlew \ + -Dorg.gradle.java.installations.paths=/private/tmp/dbx-jdk/jdk-21.0.11+10/Contents/Home,/private/tmp/dbx-jdk8/jdk8/Contents/Home \ + test shadowJar --continue +``` + +Also run the lightweight validation gates: + +```bash +python3 -m unittest discover -s scripts -p '*_test.py' +python3 scripts/validate_agents.py +python3 scripts/validate_agent_jars.py +git diff --check +``` + +Expected result: + +- Python script tests pass. +- `Agent validation passed`. +- `Agent jar validation passed` after `shadowJar` has produced jars. +- `git diff --check` prints nothing. +- Gradle finishes with `BUILD SUCCESSFUL`. + +## 3. Agent Contract Checks + +For changed agent modules: + +- `executeQuery` delegates to `JdbcExecutor.execute`. +- No module-local SQL prefix classifier is present. +- No module-local result row cap is present. +- `Statement.execute(...)` is used for arbitrary user SQL. +- Metadata methods return stable ordering for schemas, tables, columns, indexes, foreign keys, and triggers. +- User-controlled schema and table names are passed through prepared statements or quoted with `JdbcIdentifiers`. +- `connect` stores one connection, `disconnect` closes and clears it, and `testConnection` closes its temporary connection. +- A behavior test exists: + - Use `JdbcExecutionBehaviorTest` and `JdbcMetadataBehaviorTest` for agents with a local/embedded test database. + - Use `JdbcFakeExecutionBehaviorTest` for agents that require unavailable external drivers. + - Use targeted `JdbcMetadataSqlFake` tests for metadata SQL that must interpolate quoted identifiers. + +## 4. Registry And Module Checks + +Run: + +```bash +python3 scripts/validate_agents.py +``` + +This checks: + +- `versions.json` keys match included agent modules in `settings.gradle`. +- Root Gradle conventions define agent archive names. +- Agent manifests define `Agent-Label`. +- Agent manifests define `Main-Class`. +- Agent `Main-Class` source exists and the built jar contains the matching `.class`. +- Forbidden legacy execution patterns are absent. +- Disallowed JVM source/build DSL residue is absent outside Gradle output directories. + +When adding or removing an agent, update these files together: + +- `settings.gradle` +- `versions.json` +- `README.md` +- Agent module `build.gradle` for driver dependencies and manifest attributes +- Root `build.gradle` only when the module needs non-standard shared build behavior + +## 5. Driver Packaging + +For bundled drivers: + +- Verify the dependency is redistributable. +- Prefer Maven dependencies over checked-in jars. + +For external drivers: + +- Use `implementation fileTree(dir: 'libs', include: ['*.jar'])`. +- Set the manifest attribute: + +```groovy +attributes( + 'Agent-External-Driver': 'true' +) +``` + +- Confirm release registry generation emits `external_driver_required: true`. + +Current external-driver agents include BigQuery and SunDB. + +## 6. JRE Selection + +Java agents are built for the default JRE key `21`, backed by JDK 21 in the release workflow. Native agents do not require a JRE. + +If another agent needs a different runtime, update the release workflow JRE detection logic. +- Document why in the module or release notes. +- Verify DBX can download the matching runtime artifact. + +## 7. CI Expectations + +The CI workflow runs on `main` and pull requests: + +```bash +python3 -m unittest discover -s scripts -p '*_test.py' +python3 scripts/validate_agents.py +./gradlew test shadowJar --continue +python3 scripts/validate_agent_jars.py +``` + +Do not tag a release while CI is failing on `main`. + +## 8. Release Tag Flow + +Release workflow runs on tags matching `agents-v*`. + +Before tagging: + +```bash +git status --short +git log --oneline -5 +git tag --list 'agents-v*' --sort=-creatordate | head +``` + +Choose a new tag that does not already exist locally or on GitHub. For example: + +```bash +RELEASE_TAG=agents-v0.3.0 +git tag --list "$RELEASE_TAG" +git ls-remote --tags origin "$RELEASE_TAG" +``` + +Both commands should print nothing before you create the tag. + +Create and push the tag: + +```bash +git tag "$RELEASE_TAG" +git push origin main +git push origin "$RELEASE_TAG" +``` + +The release workflow will: + +- Bump changed module versions in `versions.json`. +- Build all agent shadow jars. +- Build/download JRE artifacts. +- Generate `agent-registry.json`. +- Create a GitHub release with jars, JRE archives, and registry. + +## 9. Post-Release Verification + +After the GitHub release finishes: + +- Download or inspect `agent-registry.json`. +- Confirm every expected agent appears under `drivers`. +- Confirm labels preserve spaces, for example `Google BigQuery`. +- Confirm Java agents use JRE key `21`. +- Confirm `external_driver_required` is correct. +- Confirm every jar URL, sha256, and size is present. +- Spot-check at least one agent jar manifest: + +```bash +unzip -p dbx-agent-h2.jar META-INF/MANIFEST.MF +``` + +## 10. Known Follow-Ups + +- Gradle currently reports deprecated features that will be incompatible with Gradle 10. Use `--warning-mode all` in a separate cleanup task. +- Commercial or external-driver agents still need manual smoke tests with real driver jars and databases. +- Add real containerized smoke tests only one database family at a time. diff --git a/agents/drivers/access/build.gradle b/agents/drivers/access/build.gradle new file mode 100644 index 0000000..946b6f9 --- /dev/null +++ b/agents/drivers/access/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation 'io.github.spannm:ucanaccess:5.1.5' + implementation 'io.github.spannm:jackcess-encrypt:5.1.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Microsoft Access', 'Main-Class': 'com.dbx.agent.access.AccessAgent') + } +} diff --git a/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java b/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java new file mode 100644 index 0000000..90311ca --- /dev/null +++ b/agents/drivers/access/src/main/java/com/dbx/agent/access/AccessAgent.java @@ -0,0 +1,372 @@ +package com.dbx.agent.access; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class AccessAgent extends BaseDatabaseAgent { + private static final String ENCRYPTED_ACCESS_OPENER = EncryptedAccessOpener.class.getName(); + private Connection connection; + private String databaseFile = ""; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + Class.forName("net.ucanaccess.jdbc.UcanaccessDriver"); + String file = databasePath(params); + String url = jdbcUrl(params, true); + connection = DriverManager.getConnection(url, params.getUsername(), params.getPassword()); + databaseFile = file; + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + Class.forName("net.ucanaccess.jdbc.UcanaccessDriver"); + String file = databasePath(params); + if (file.isBlank() || !Files.exists(Path.of(file))) { + return false; + } + try (Connection conn = DriverManager.getConnection(jdbcUrl(params, false), params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + } + + @Override + public List listDatabases() { + String name = "default"; + if (!databaseFile.isBlank()) { + Path fileName = Path.of(databaseFile).getFileName(); + if (fileName != null && !fileName.toString().isBlank()) { + name = fileName.toString(); + } + } + return Collections.singletonList(new DatabaseInfo(name)); + } + + @Override + public List listSchemas() { + return Collections.emptyList(); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + DatabaseMetaData meta = requireConnected().getMetaData(); + List result = new ArrayList<>(); + try (ResultSet rs = meta.getTables(null, null, "%", new String[]{"TABLE", "VIEW"})) { + while (rs.next()) { + String name = rs.getString("TABLE_NAME"); + if (name == null || startsWithIgnoreCase(name, "MSys")) { + continue; + } + result.add(new TableInfo(name, normalizeTableType(rs.getString("TABLE_TYPE")), null)); + } + } + result.sort(Comparator.comparing(table -> table.getName().toLowerCase(Locale.ROOT))); + return result; + }); + } + + @Override + public List listObjects(String schema) { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), null, table.getComment())); + } + return result; + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + DatabaseMetaData meta = requireConnected().getMetaData(); + Set primaryKeys = primaryKeyColumns(meta, table); + List result = new ArrayList<>(); + try (ResultSet rs = meta.getColumns(null, null, table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + name, + typeName(rs), + rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + rs.getString("COLUMN_DEF"), + primaryKeys.contains(name), + null, + rs.getString("REMARKS"), + intObject(rs, "COLUMN_SIZE"), + intObject(rs, "DECIMAL_DIGITS"), + intObject(rs, "COLUMN_SIZE") + )); + } + } + result.sort(Comparator.comparingInt(column -> columnOrder(meta, table, column.getName()))); + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + DatabaseMetaData meta = requireConnected().getMetaData(); + Set primaryKeys = primaryKeyColumns(meta, table); + Map> columnsByIndex = new LinkedHashMap<>(); + Map uniqueByIndex = new LinkedHashMap<>(); + Map primaryByIndex = new LinkedHashMap<>(); + Map typeByIndex = new LinkedHashMap<>(); + + try (ResultSet rs = meta.getIndexInfo(null, null, table, false, false)) { + while (rs.next()) { + if (rs.getShort("TYPE") == DatabaseMetaData.tableIndexStatistic) { + continue; + } + String indexName = rs.getString("INDEX_NAME"); + String columnName = rs.getString("COLUMN_NAME"); + if (indexName == null || columnName == null) { + continue; + } + short ordinal = rs.getShort("ORDINAL_POSITION"); + + columnsByIndex.computeIfAbsent(indexName, ignored -> new ArrayList<>()).add(new OrdinalColumn(ordinal, columnName)); + uniqueByIndex.put(indexName, !rs.getBoolean("NON_UNIQUE")); + primaryByIndex.put(indexName, primaryKeys.contains(columnName) || indexName.equalsIgnoreCase("PrimaryKey")); + typeByIndex.put(indexName, indexTypeName(rs.getShort("TYPE"))); + } + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : columnsByIndex.entrySet()) { + String name = entry.getKey(); + List pairs = entry.getValue(); + pairs.sort(Comparator.comparingInt(pair -> pair.ordinal)); + List columns = new ArrayList<>(); + for (OrdinalColumn pair : pairs) { + columns.add(pair.column); + } + result.add(new IndexInfo( + name, + columns, + uniqueByIndex.getOrDefault(name, false), + primaryByIndex.getOrDefault(name, false), + null, + typeByIndex.get(name), + null, + null + )); + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + DatabaseMetaData meta = requireConnected().getMetaData(); + List result = new ArrayList<>(); + try (ResultSet rs = meta.getImportedKeys(null, null, table)) { + while (rs.next()) { + String name = rs.getString("FK_NAME"); + result.add(new ForeignKeyInfo( + name == null ? "" : name, + rs.getString("FKCOLUMN_NAME"), + rs.getString("PKTABLE_NAME"), + rs.getString("PKCOLUMN_NAME") + )); + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + JdbcExecutor.INSTANCE::defaultResultValue + ); + } + + @Override + public String setSchemaSQL(String schema) { + return ""; + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private Set primaryKeyColumns(DatabaseMetaData meta, String table) throws Exception { + Set result = new LinkedHashSet<>(); + try (ResultSet rs = meta.getPrimaryKeys(null, null, table)) { + while (rs.next()) { + result.add(rs.getString("COLUMN_NAME")); + } + } + return result; + } + + private int columnOrder(DatabaseMetaData meta, String table, String column) { + return unchecked(() -> { + try (ResultSet rs = meta.getColumns(null, null, table, column)) { + return rs.next() ? rs.getInt("ORDINAL_POSITION") : Integer.MAX_VALUE; + } + }); + } + + private static String databasePath(ConnectParams params) { + if (startsWithIgnoreCase(params.getConnection_string(), "jdbc:ucanaccess:")) { + return stripJdbcPrefix(params.getConnection_string()); + } + if (startsWithIgnoreCase(params.getDatabase(), "jdbc:ucanaccess:")) { + return stripJdbcPrefix(params.getDatabase()); + } + if (!params.getHost().isBlank()) { + return params.getHost(); + } + return params.getDatabase(); + } + + static String jdbcUrl(ConnectParams params, boolean createIfMissing) { + String rawUrl; + if (startsWithIgnoreCase(params.getConnection_string(), "jdbc:ucanaccess:")) { + rawUrl = params.getConnection_string(); + } else if (startsWithIgnoreCase(params.getDatabase(), "jdbc:ucanaccess:")) { + rawUrl = params.getDatabase(); + } else { + rawUrl = "jdbc:ucanaccess://" + databasePath(params); + } + rawUrl = appendUCanAccessParams(rawUrl, params.getUrl_params()); + + // The codec opener is safe for regular files and enables encrypted Access files when a password is supplied. + if (!containsIgnoreCase(rawUrl, "jackcessOpener=")) { + rawUrl += (rawUrl.endsWith(";") ? "" : ";") + "jackcessOpener=" + ENCRYPTED_ACCESS_OPENER; + } + + if (!createIfMissing || Files.exists(Path.of(databasePath(params)))) { + return rawUrl; + } + if (containsIgnoreCase(rawUrl, "newDatabaseVersion=")) { + return rawUrl; + } + return rawUrl + ";newDatabaseVersion=V2010"; + } + + private static String appendUCanAccessParams(String url, String urlParams) { + String params = trimUCanAccessParams(urlParams); + if (params.isEmpty()) { + return url; + } + return url + (url.endsWith(";") ? "" : ";") + params; + } + + private static String trimUCanAccessParams(String urlParams) { + String value = urlParams == null ? "" : urlParams.trim(); + while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) { + value = value.substring(1); + } + return value; + } + + private static String normalizeTableType(String value) { + if (value != null && value.toUpperCase(Locale.ROOT).equals("BASE TABLE")) { + return "TABLE"; + } + return value == null ? "TABLE" : value; + } + + private static String indexTypeName(short value) { + if (value == DatabaseMetaData.tableIndexClustered) { + return "CLUSTERED"; + } + if (value == DatabaseMetaData.tableIndexHashed) { + return "HASHED"; + } + if (value == DatabaseMetaData.tableIndexOther) { + return "OTHER"; + } + return ""; + } + + private static String typeName(ResultSet rs) throws Exception { + String value = rs.getString("TYPE_NAME"); + return value == null ? Integer.toString(rs.getInt("DATA_TYPE")) : value; + } + + private static Integer intObject(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number number ? number.intValue() : null; + } + + private static String stripJdbcPrefix(String value) { + return substringBefore(value.replaceFirst("(?i)^jdbc:ucanaccess://", ""), ';'); + } + + private static String substringBefore(String value, char delimiter) { + int index = value.indexOf(delimiter); + return index >= 0 ? value.substring(0, index) : value; + } + + private static boolean startsWithIgnoreCase(String value, String prefix) { + return value.regionMatches(true, 0, prefix, 0, prefix.length()); + } + + private static boolean containsIgnoreCase(String value, String needle) { + return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT)); + } + + private record OrdinalColumn(short ordinal, String column) { + } + + public static void main(String[] args) { + new JsonRpcServer(new AccessAgent()).run(); + } +} diff --git a/agents/drivers/access/src/main/java/com/dbx/agent/access/EncryptedAccessOpener.java b/agents/drivers/access/src/main/java/com/dbx/agent/access/EncryptedAccessOpener.java new file mode 100644 index 0000000..b298f45 --- /dev/null +++ b/agents/drivers/access/src/main/java/com/dbx/agent/access/EncryptedAccessOpener.java @@ -0,0 +1,20 @@ +package com.dbx.agent.access; + +import io.github.spannm.jackcess.Database; +import io.github.spannm.jackcess.DatabaseBuilder; +import io.github.spannm.jackcess.encrypt.CryptCodecProvider; +import java.io.File; +import java.io.IOException; +import net.ucanaccess.jdbc.IJackcessOpenerInterface; + +public final class EncryptedAccessOpener implements IJackcessOpenerInterface { + @Override + public Database open(File file, String password) throws IOException { + DatabaseBuilder builder = new DatabaseBuilder(file); + // UCanAccess flushes changes at transaction boundaries, so Jackcess auto-sync is unnecessary overhead. + builder.withAutoSync(false); + builder.withCodecProvider(new CryptCodecProvider(password)); + builder.withReadOnly(false); + return builder.open(); + } +} diff --git a/agents/drivers/access/src/test/java/com/dbx/agent/access/AccessAgentTest.java b/agents/drivers/access/src/test/java/com/dbx/agent/access/AccessAgentTest.java new file mode 100644 index 0000000..1ef554a --- /dev/null +++ b/agents/drivers/access/src/test/java/com/dbx/agent/access/AccessAgentTest.java @@ -0,0 +1,155 @@ +package com.dbx.agent.access; + +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.test.JdbcConnectedAgentTest; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AccessAgentTest extends JdbcConnectedAgentTest { + @TempDir + Path tempDir; + + @Override + protected DatabaseAgent createConnectedAgent(String databaseName) { + Path file = tempDir.resolve(databaseName + ".accdb"); + AccessAgent agent = new AccessAgent(); + agent.connect(new ConnectParams("", 0, file.toString(), "", "", "", "", false)); + return agent; + } + + @Test + void connectsToAnAccessFileAndReturnsTableMetadata() { + withAgent("metadata", agent -> { + agent.executeQuery( + "CREATE TABLE People (ID COUNTER PRIMARY KEY, Name TEXT(64), Active YESNO)", + null, + new ExecuteQueryOptions() + ); + agent.executeQuery("INSERT INTO People (Name, Active) VALUES ('Ada', TRUE)", null, new ExecuteQueryOptions()); + + var tables = agent.listTables(""); + var columns = agent.getColumns("", "People"); + var result = agent.executeQuery("SELECT Name, Active FROM People", null, new ExecuteQueryOptions()); + + Assertions.assertTrue(tables.stream().anyMatch(table -> table.getName().equals("People"))); + Assertions.assertEquals(List.of("ID", "Name", "Active"), columns.stream().map(column -> column.getName()).toList()); + Assertions.assertTrue(columns.stream() + .filter(column -> column.getName().equals("ID")) + .findFirst() + .orElseThrow() + .getIs_primary_key()); + Assertions.assertEquals(List.of("Name", "Active"), result.getColumns()); + Assertions.assertEquals("Ada", result.getRows().get(0).get(0)); + Assertions.assertEquals(true, result.getRows().get(0).get(1)); + }); + } + + @Test + void returnsEmptySchemaListForAccessFiles() { + withAgent("schemas", agent -> { + Assertions.assertEquals(List.of(), agent.listSchemas()); + Assertions.assertFalse(agent.testConnection( + new ConnectParams("", 0, tempDir.resolve("missing.accdb").toString(), "", "", "", "", false) + )); + }); + } + + @Test + void appendsUcanaccessUrlParams() { + Path file = tempDir.resolve("params.accdb"); + ConnectParams params = new ConnectParams("", 0, file.toString(), "", "", "memory=false;ignoreCase=true", "", false); + + Assertions.assertEquals( + "jdbc:ucanaccess://" + file + ";memory=false;ignoreCase=true;jackcessOpener=com.dbx.agent.access.EncryptedAccessOpener;newDatabaseVersion=V2010", + AccessAgent.jdbcUrl(params, true) + ); + } + + @Test + void trimsUcanaccessUrlParamSeparators() { + Path file = tempDir.resolve("params-existing.accdb"); + ConnectParams params = new ConnectParams("", 0, "jdbc:ucanaccess://" + file + ";", "", "", ";memory=false", "", false); + + Assertions.assertEquals( + "jdbc:ucanaccess://" + file + ";memory=false;jackcessOpener=com.dbx.agent.access.EncryptedAccessOpener;newDatabaseVersion=V2010", + AccessAgent.jdbcUrl(params, true) + ); + } + + @Test + void preservesCustomJackcessOpener() { + Path file = tempDir.resolve("custom-opener.accdb"); + ConnectParams params = new ConnectParams( + "", 0, file.toString(), "", "", "jackcessOpener=example.CustomOpener", "", false + ); + + Assertions.assertEquals( + "jdbc:ucanaccess://" + file + ";jackcessOpener=example.CustomOpener;newDatabaseVersion=V2010", + AccessAgent.jdbcUrl(params, true) + ); + } + + @Test + void connectsToEncryptedAccessFile() throws Exception { + Path source = Path.of(getClass().getResource("/db2007-enc.accdb").toURI()); + Path file = tempDir.resolve("encrypted.accdb"); + Files.copy(source, file); + AccessAgent agent = new AccessAgent(); + + try { + agent.connect(new ConnectParams("", 0, file.toString(), "", "Test123", "", "", false)); + Assertions.assertFalse(agent.listTables("").isEmpty()); + } finally { + agent.disconnect(); + } + } + + @Test + void rejectsIncorrectEncryptedAccessPassword() throws Exception { + Path source = Path.of(getClass().getResource("/db2007-enc.accdb").toURI()); + Path file = tempDir.resolve("encrypted-wrong-password.accdb"); + Files.copy(source, file); + AccessAgent agent = new AccessAgent(); + + RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> + agent.connect(new ConnectParams("", 0, file.toString(), "", "wrong", "", "", false)) + ); + Assertions.assertTrue(error.getMessage().contains("Incorrect password provided")); + } + + @Test + void supportsLimitOffsetSqlAndCursorResultPages() { + withAgent("pages", agent -> { + agent.executeQuery("CREATE TABLE Items (ID COUNTER PRIMARY KEY, Value INTEGER)", null, new ExecuteQueryOptions()); + for (int i = 1; i <= 5; i++) { + agent.executeQuery("INSERT INTO Items (Value) VALUES (" + i + ")", null, new ExecuteQueryOptions()); + } + + var limited = agent.executeQuery( + "SELECT [Value] FROM [Items] ORDER BY [ID] LIMIT 2 OFFSET 1", + null, + new ExecuteQueryOptions() + ); + var firstPage = agent.executeQueryPage( + "SELECT Value FROM Items ORDER BY ID", + null, + new QueryPageOptions(2, null, 5) + ); + String sessionId = firstPage.getSession_id(); + Assertions.assertNotNull(sessionId); + var secondPage = agent.fetchQueryPage(sessionId, 2); + + Assertions.assertEquals(List.of(2, 3), limited.getRows().stream().map(row -> row.get(0)).toList()); + Assertions.assertEquals(List.of(1, 2), firstPage.getRows().stream().map(row -> row.get(0)).toList()); + Assertions.assertEquals(true, firstPage.getHas_more()); + Assertions.assertEquals(List.of(3, 4), secondPage.getRows().stream().map(row -> row.get(0)).toList()); + }); + } +} diff --git a/agents/drivers/access/src/test/resources/db2007-enc.accdb b/agents/drivers/access/src/test/resources/db2007-enc.accdb new file mode 100644 index 0000000..3707e94 Binary files /dev/null and b/agents/drivers/access/src/test/resources/db2007-enc.accdb differ diff --git a/agents/drivers/bigquery/build.gradle b/agents/drivers/bigquery/build.gradle new file mode 100644 index 0000000..ae0da1b --- /dev/null +++ b/agents/drivers/bigquery/build.gradle @@ -0,0 +1,13 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'Google BigQuery', + 'Agent-External-Driver': 'true', + 'Main-Class': 'com.dbx.agent.bigquery.BigQueryAgent' + ) + } +} diff --git a/agents/drivers/bigquery/src/main/java/com/dbx/agent/bigquery/BigQueryAgent.java b/agents/drivers/bigquery/src/main/java/com/dbx/agent/bigquery/BigQueryAgent.java new file mode 100644 index 0000000..71d64bf --- /dev/null +++ b/agents/drivers/bigquery/src/main/java/com/dbx/agent/bigquery/BigQueryAgent.java @@ -0,0 +1,234 @@ +package com.dbx.agent.bigquery; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public final class BigQueryAgent extends AbstractJdbcAgent { + + @Override + protected String driverClass() { + return "com.simba.googlebigquery.jdbc.Driver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SELECT schema_name FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY schema_name")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String quotedSchema = JdbcIdentifiers.INSTANCE.backtick(schema); + String sql = "SELECT table_name, table_type FROM " + quotedSchema + ".INFORMATION_SCHEMA.TABLES ORDER BY table_name"; + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("table_name"), + normalizeTableType(rs.getString("table_type")), + null + )); + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + if (hasLikeWildcard(normalized.getFilter())) { + return normalized.filterTables(listTables(schema)); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + String quotedSchema = JdbcIdentifiers.INSTANCE.backtick(schema); + StringBuilder sql = new StringBuilder("SELECT table_name, table_type FROM ") + .append(quotedSchema) + .append(".INFORMATION_SCHEMA.TABLES WHERE 1 = 1"); + appendBigQueryTableTypePredicate(sql, args, constraints); + if (constraints.hasFilter()) { + sql.append(" AND UPPER(table_name) LIKE ?"); + args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + sql.append(" ORDER BY table_name"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("table_name"), + normalizeTableType(rs.getString("table_type")), + null + )); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String quotedSchema = JdbcIdentifiers.INSTANCE.backtick(schema); + String sql = "SELECT column_name, data_type, is_nullable " + + "FROM " + quotedSchema + ".INFORMATION_SCHEMA.COLUMNS " + + "WHERE table_name = ? " + + "ORDER BY ordinal_position"; + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ColumnInfo( + rs.getString("column_name"), + rs.getString("data_type"), + "YES".equals(rs.getString("is_nullable")), + null, + false, + null, + null, + null, + null, + null + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + return ""; + } + + static String buildUrl(ConnectParams params) { + String url = "jdbc:bigquery://" + params.getHost() + ":" + params.getPort() + ";ProjectId=" + params.getDatabase(); + String extraParams = trimSemicolons(params.getUrl_params()); + return extraParams.isBlank() ? url : url + ";" + extraParams; + } + + private static String trimSemicolons(String value) { + if (value == null) { + return ""; + } + return value.trim().replaceAll("^;+", "").replaceAll(";+\\s*$", ""); + } + + private static String normalizeTableType(String type) { + if ("BASE TABLE".equals(type)) { + return "TABLE"; + } + if ("MATERIALIZED VIEW".equals(type)) { + return "MATERIALIZED_VIEW"; + } + return type; + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static void appendBigQueryTableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (constraints.tableTypeAllowed("MATERIALIZED_VIEW")) { + types.add("MATERIALIZED VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND table_type IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static boolean hasLikeWildcard(String value) { + return value != null && (value.contains("%") || value.contains("_") || value.contains("\\")); + } + + public static void main(String[] args) { + new JsonRpcServer(new BigQueryAgent()).run(); + } +} diff --git a/agents/drivers/bigquery/src/test/java/com/dbx/agent/bigquery/BigQueryAgentMetadataTest.java b/agents/drivers/bigquery/src/test/java/com/dbx/agent/bigquery/BigQueryAgentMetadataTest.java new file mode 100644 index 0000000..5a6e80d --- /dev/null +++ b/agents/drivers/bigquery/src/test/java/com/dbx/agent/bigquery/BigQueryAgentMetadataTest.java @@ -0,0 +1,65 @@ +package com.dbx.agent.bigquery; + +import com.dbx.agent.test.TestSupport; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.JdbcAgentFake; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import java.sql.Connection; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class BigQueryAgentMetadataTest { + @Test + void quotesSchemaIdentifiersInMetadataSql() { + BigQueryAgent agent = new BigQueryAgent(); + Connection fake = JdbcMetadataSqlFake.connection(); + TestSupport.setPrivateConnection(agent, fake); + + agent.listTables("bad`schema"); + agent.getColumns("bad`schema", "sample"); + + Assertions.assertTrue( + JdbcMetadataSqlFake.statements.stream() + .anyMatch(statement -> statement.contains("FROM `bad``schema`.INFORMATION_SCHEMA.TABLES")) + ); + Assertions.assertTrue( + JdbcMetadataSqlFake.statements.stream() + .anyMatch(statement -> statement.contains("FROM `bad``schema`.INFORMATION_SCHEMA.COLUMNS")) + ); + } + + @Test + void buildUrlAppendsAuthenticationUrlParams() { + ConnectParams params = new ConnectParams( + "https://www.googleapis.com/bigquery/v2", + 443, + "demo-project", + "", + "", + "OAuthType=0;OAuthServiceAcctEmail=svc@demo.iam.gserviceaccount.com;OAuthPvtKeyPath=C:\\keys\\demo.json", + "", + false + ); + + Assertions.assertEquals( + "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;ProjectId=demo-project;OAuthType=0;OAuthServiceAcctEmail=svc@demo.iam.gserviceaccount.com;OAuthPvtKeyPath=C:\\keys\\demo.json", + BigQueryAgent.buildUrl(params) + ); + } + + @Test + void constrainedTableMetadataPushesFilterTypesAndPaging() { + BigQueryAgent agent = new BigQueryAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("demo`dataset", new MetadataListConstraints("ord", 25, 50, List.of("TABLE", "VIEW"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM `demo``dataset`.INFORMATION_SCHEMA.TABLES"), sql); + Assertions.assertTrue(sql.contains("table_type IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("UPPER(table_name) LIKE ?"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql); + } +} diff --git a/agents/drivers/bigquery/src/test/java/com/dbx/agent/bigquery/BigQueryAgentTest.java b/agents/drivers/bigquery/src/test/java/com/dbx/agent/bigquery/BigQueryAgentTest.java new file mode 100644 index 0000000..147d1f6 --- /dev/null +++ b/agents/drivers/bigquery/src/test/java/com/dbx/agent/bigquery/BigQueryAgentTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.bigquery; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; + +class BigQueryAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new BigQueryAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL dataset.proc()"; + } +} diff --git a/agents/drivers/cassandra/build.gradle b/agents/drivers/cassandra/build.gradle new file mode 100644 index 0000000..38a8d6f --- /dev/null +++ b/agents/drivers/cassandra/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.ing.data:cassandra-jdbc-wrapper:4.12.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Apache Cassandra', 'Main-Class': 'com.dbx.agent.cassandra.CassandraAgent') + } +} diff --git a/agents/drivers/cassandra/src/main/java/com/dbx/agent/cassandra/CassandraAgent.java b/agents/drivers/cassandra/src/main/java/com/dbx/agent/cassandra/CassandraAgent.java new file mode 100644 index 0000000..6ef9f34 --- /dev/null +++ b/agents/drivers/cassandra/src/main/java/com/dbx/agent/cassandra/CassandraAgent.java @@ -0,0 +1,194 @@ +package com.dbx.agent.cassandra; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class CassandraAgent extends AbstractJdbcAgent { + private static final Pattern TARGET_PATTERN = Pattern.compile("target[\"']?\\s*[:=]\\s*[\"']?([\\w]+)"); + + @Override + protected String driverClass() { + return "com.ing.data.cassandra.jdbc.CassandraDriver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT keyspace_name FROM system_schema.keyspaces"; + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + result.sort(Comparator.comparing(DatabaseInfo::getName)); + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1), "TABLE", null)); + } + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + return result; + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT column_name, type, kind FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ?"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String kind = coalesce(rs.getString("kind")); + boolean isPrimaryKey = "partition_key".equals(kind) || "clustering".equals(kind); + result.add(new ColumnInfo( + rs.getString("column_name"), + coalesce(rs.getString("type"), "unknown"), + !isPrimaryKey, + null, + isPrimaryKey, + kind.trim().isEmpty() ? null : kind, + null, + null, + null, + null + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT index_name, options FROM system_schema.indexes WHERE keyspace_name = ? AND table_name = ?"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String indexName = coalesce(rs.getString("index_name")); + String options = coalesce(rs.getString("options")); + result.add(new IndexInfo( + indexName, + targetColumns(options), + false, + false, + null, + null, + null, + null + )); + } + } + } + result.sort(Comparator.comparing(IndexInfo::getName)); + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + static String buildUrl(ConnectParams params) { + String baseUrl = "jdbc:cassandra://" + params.getHost() + ":" + params.getPort(); + String keyspace = coalesce(params.getDatabase()).trim(); + // Cassandra rejects an empty keyspace path; omit it so DBX can connect first and list keyspaces. + String url = keyspace.isEmpty() ? baseUrl : baseUrl + "/" + keyspace; + // Multi-DC clusters require localdatacenter= + String extraParams = coalesce(params.getUrl_params()).trim(); + while (extraParams.startsWith("?") || extraParams.startsWith("&")) { + extraParams = extraParams.substring(1); + } + return extraParams.isEmpty() ? url : url + "?" + extraParams; + } + + private static List targetColumns(String options) { + Matcher matcher = TARGET_PATTERN.matcher(options); + if (!matcher.find()) { + return Collections.emptyList(); + } + return Collections.singletonList(matcher.group(1)); + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + private static String coalesce(String value, String fallback) { + return value == null ? fallback : value; + } + + public static void main(String[] args) { + new JsonRpcServer(new CassandraAgent()).run(); + } +} diff --git a/agents/drivers/cassandra/src/test/java/com/dbx/agent/cassandra/CassandraAgentTest.java b/agents/drivers/cassandra/src/test/java/com/dbx/agent/cassandra/CassandraAgentTest.java new file mode 100644 index 0000000..4eb9e6b --- /dev/null +++ b/agents/drivers/cassandra/src/test/java/com/dbx/agent/cassandra/CassandraAgentTest.java @@ -0,0 +1,52 @@ +package com.dbx.agent.cassandra; + +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CassandraAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new CassandraAgent(); + } + + @Override + protected String resultSetSql() { + return "LIST ROLES"; + } + + @Test + void buildsServerUrlWhenKeyspaceIsEmpty() { + ConnectParams params = new ConnectParams("127.0.0.1", 9042, "", "cassandra", "cassandra", "", "", false); + + assertEquals("jdbc:cassandra://127.0.0.1:9042", CassandraAgent.buildUrl(params)); + } + + @Test + void buildsKeyspaceUrlWhenKeyspaceIsSet() { + ConnectParams params = new ConnectParams("127.0.0.1", 9042, "app_keyspace", "cassandra", "cassandra", "", "", false); + + assertEquals("jdbc:cassandra://127.0.0.1:9042/app_keyspace", CassandraAgent.buildUrl(params)); + } + + @Test + void appendsUrlParamsForMultiDcLocalDatacenter() { + ConnectParams params = new ConnectParams( + "127.0.0.1", 9042, "app_keyspace", "cassandra", "cassandra", "localdatacenter=dc1", "", false + ); + + assertEquals("jdbc:cassandra://127.0.0.1:9042/app_keyspace?localdatacenter=dc1", CassandraAgent.buildUrl(params)); + } + + @Test + void stripsLeadingQuestionMarkFromUrlParams() { + ConnectParams params = new ConnectParams( + "127.0.0.1", 9042, "", "cassandra", "cassandra", "?localdatacenter=dc1", "", false + ); + + assertEquals("jdbc:cassandra://127.0.0.1:9042?localdatacenter=dc1", CassandraAgent.buildUrl(params)); + } +} diff --git a/agents/drivers/dameng/build.gradle b/agents/drivers/dameng/build.gradle new file mode 100644 index 0000000..6a80eb9 --- /dev/null +++ b/agents/drivers/dameng/build.gradle @@ -0,0 +1,13 @@ +dependencies { + // Local override: place a newer DmJdbcDriver*.jar in libs/ for versions + // not yet published to Maven Central (the fileTree takes priority over Maven). + implementation fileTree(dir: 'libs', include: ['*.jar']) + // DmJdbcDriver8 supports JDK 8+ (compatible with JRE 21 used by DAMENG agent). + implementation 'com.dameng:DmJdbcDriver8:8.1.5.45' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': '达梦 DM8', 'Main-Class': 'com.dbx.agent.dameng.DamengAgent') + } +} diff --git a/agents/drivers/dameng/libs/.gitkeep b/agents/drivers/dameng/libs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java new file mode 100644 index 0000000..103b206 --- /dev/null +++ b/agents/drivers/dameng/src/main/java/com/dbx/agent/dameng/DamengAgent.java @@ -0,0 +1,1327 @@ +package com.dbx.agent.dameng; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.io.PrintStream; +import java.io.Reader; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLXML; +import java.sql.Statement; +import java.sql.Types; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class DamengAgent extends BaseDatabaseAgent { + private static final String AGENT_VERSION = "9999.06.04.1-fix-default"; + private static final Set SYSTEM_USERS = Set.of( + "SYS", "SYSAUDITOR", "SYSSSO", "CTISYS", + "SYS_DBA", "_SYS_STATISTICS", "SYS_PHM" + ); + + private Connection connection; + private String connectedUsername; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + withSuppressedStdout(() -> { + Class.forName("dm.jdbc.driver.DmDriver"); + connection = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword()); + connectedUsername = params.getUsername(); + }); + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + return withSuppressedStdout(() -> { + Class.forName("dm.jdbc.driver.DmDriver"); + try (Connection conn = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + }); + } + + /** + * The DM JDBC driver writes a banner to {@code System.out} during + * {@code Class.forName} / driver initialization. This corrupts the + * JSON-RPC stdout protocol. Temporarily redirect {@code System.out} + * to {@code System.err} so driver output lands on stderr instead. + */ + private static T withSuppressedStdout(ThrowingSupplier action) throws Exception { + PrintStream originalOut = System.out; + try { + System.setOut(System.err); + return action.get(); + } finally { + System.setOut(originalOut); + } + } + + private static void withSuppressedStdout(ThrowingRunnable action) throws Exception { + PrintStream originalOut = System.out; + try { + System.setOut(System.err); + action.run(); + } finally { + System.setOut(originalOut); + } + } + + @Override + public List listDatabases() { + return unchecked(() -> listVisibleUsers().stream().map(DatabaseInfo::new).toList()); + } + + @Override + public List listSchemas() { + return unchecked(this::listVisibleSchemas); + } + + private List listVisibleUsers() throws Exception { + List result = new ArrayList<>(); + String placeholders = String.join(",", SYSTEM_USERS.stream().map(user -> "?").toList()); + String sql = "SELECT USERNAME FROM ALL_USERS WHERE USERNAME NOT IN (" + placeholders + ") ORDER BY USERNAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + int index = 1; + for (String user : SYSTEM_USERS) { + stmt.setString(index++, user); + } + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } + } + return result; + } + + private List listVisibleSchemas() throws Exception { + List result = new ArrayList<>(); + String placeholders = String.join(",", SYSTEM_USERS.stream().map(user -> "?").toList()); + String sql = "SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND NAME NOT IN (" + placeholders + ") ORDER BY NAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + int index = 1; + for (String user : SYSTEM_USERS) { + stmt.setString(index++, user); + } + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } + } + return result; + } + + @Override + public List listTables(String schema) { + // Keep this call on the legacy object-type overload; the common agent + // also exposes a constraints overload for paged metadata listing. + return listTables(schema, (List) null); + } + + @Override + public List listTables(String schema, List objectTypes) { + return unchecked(() -> { + Map tablesByName = new LinkedHashMap<>(); + if (objectTypesInclude(objectTypes, "TABLE")) { + loadTableOrView(schema, "TABLE", tablesByName); + } + if (objectTypesInclude(objectTypes, "VIEW")) { + loadTableOrView(schema, "VIEW", tablesByName); + } + if (objectTypesInclude(objectTypes, "MATERIALIZED_VIEW")) { + loadMaterializedViews(schema, tablesByName); + } + List result = new ArrayList<>(tablesByName.values()); + result.sort(Comparator.comparing(TableInfo::getName)); + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + return queryConstrainedTables(schema, normalized); + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + if (!constraints.includesTableLikeTypes()) { + return List.of(); + } + try { + return unchecked(() -> { + MetadataQuery query = buildConstrainedTablesQuery(schema, constraints); + List result = new ArrayList<>(); + try (PreparedStatement stmt = requireConnected().prepareStatement(query.sql())) { + bind(stmt, query.args()); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString("TABLE_NAME"), normalizeObjectType(rs.getString("TABLE_TYPE")), rs.getString("COMMENTS"))); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } catch (RuntimeException e) { + // Restricted Dameng catalog views vary by version; fall back to the + // legacy metadata path so browsing still works when pushdown fails. + return constraints.filterTables(listTables(schema)); + } + } + + static MetadataQuery buildConstrainedTablesQuery(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder(""" + SELECT o.OBJECT_NAME AS TABLE_NAME, + CASE WHEN o.OBJECT_TYPE = 'MATERIALIZED VIEW' THEN 'MATERIALIZED_VIEW' ELSE o.OBJECT_TYPE END AS TABLE_TYPE, + c.COMMENTS + FROM ALL_OBJECTS o + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME + WHERE o.OWNER = ? + """.stripIndent().trim()); + args.add(schema); + appendDamengObjectTypePredicate(sql, args, normalized, true); + sql.append(" AND (o.OBJECT_TYPE <> 'TABLE' OR o.OBJECT_NAME NOT LIKE 'MTAB$_%')"); + appendNameFilter(sql, args, "o.OBJECT_NAME", normalized); + sql.append(" ORDER BY o.OBJECT_NAME"); + appendLimitOffset(sql, args, normalized); + return new MetadataQuery(sql.toString(), args); + } + + private void loadTableOrView(String schema, String tableType, Map tablesByName) { + if (!loadTableOrViewFromAllObjects(schema, tableType, tablesByName)) { + loadTableOrViewFromComments(schema, tableType, tablesByName); + } + if ("VIEW".equals(tableType)) { + removeMaterializedViewsFromRegularViews(schema, tablesByName); + } + } + + private boolean loadTableOrViewFromAllObjects(String schema, String tableType, Map tablesByName) { + String sql = (""" + SELECT o.OBJECT_NAME AS TABLE_NAME, c.COMMENTS + FROM ALL_OBJECTS o + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME + WHERE o.OWNER = ? AND o.OBJECT_TYPE = '%s' AND ( (o.OBJECT_TYPE = 'TABLE' AND o.OBJECT_NAME NOT LIKE 'MTAB$_%%') OR o.OBJECT_TYPE = 'VIEW') + ORDER BY o.OBJECT_NAME + """).formatted(tableType).stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), tableType, rs.getString(2)); + } + } + return true; + } catch (Exception ignored) { + return false; + } + } + + private void loadTableOrViewFromComments(String schema, String tableType, Map tablesByName) { + String tableNameFilter = "TABLE".equals(tableType) ? " AND TABLE_NAME NOT LIKE 'MTAB$_%'" : ""; + String sql = (""" + SELECT TABLE_NAME, COMMENTS + FROM ALL_TAB_COMMENTS + WHERE OWNER = ? AND TABLE_TYPE = '%s'%s + ORDER BY TABLE_NAME + """).formatted(tableType, tableNameFilter).stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), tableType, rs.getString(2)); + } + } + } catch (Exception ignored) { + // Keep metadata browsing usable even when optional catalog views are restricted. + } + } + + private void loadMaterializedViews(String schema, Map tablesByName) { + loadMaterializedViewsFromAllObjects(schema, tablesByName); + } + + private void loadMaterializedViewsFromAllObjects(String schema, Map tablesByName) { + String sql = """ + SELECT m.MVIEW_NAME AS TABLE_NAME, c.COMMENTS + FROM USER_MVIEWS m LEFT JOIN ALL_OBJECTS o ON m.SCHID = o.OBJECT_ID + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = m.MVIEW_NAME + WHERE o.OWNER = ? + ORDER BY TABLE_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), "MATERIALIZED_VIEW", rs.getString(2)); + } + } + } catch (Exception ignored) { + // Older or restricted Dameng catalogs may not expose this object type. + } + } + + private void loadMaterializedViewsFromUserMviews(String schema, Map tablesByName) { + if (!schemaMatchesConnectedUser(schema)) { + return; + } + loadUserMviews("SELECT MVIEW_NAME FROM USER_MVIEWS", tablesByName); + } + + private boolean loadUserMviews(String sql, Map tablesByName) { + try (PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + addTableInfo(tablesByName, rs.getString(1), "MATERIALIZED_VIEW", null); + } + return true; + } catch (Exception ignored) { + return false; + } + } + + private void removeMaterializedViewsFromRegularViews(String schema, Map tablesByName) { + /*if (!schemaMatchesConnectedUser(schema)) { + return; + }*/ + for (String name : listUserMviewNames()) { + tablesByName.remove(name); + } + } + + private Set listUserMviewNames() { + Set names = new java.util.HashSet<>(); + String sql = "SELECT MVIEW_NAME FROM USER_MVIEWS"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String key = metadataNameKey(rs.getString(1)); + if (!key.isEmpty()) { + names.add(key); + } + } + } catch (Exception ignored) { + // Some Dameng versions or users do not expose USER_MVIEWS. + } + return names; + } + + private boolean schemaMatchesConnectedUser(String schema) { + return connectedUsername != null + && schema != null + && !connectedUsername.isBlank() + && schema.equalsIgnoreCase(connectedUsername); + } + + private static void addTableInfo(Map tablesByName, String name, String tableType, String comment) { + String key = metadataNameKey(name); + if (!key.isEmpty()) { + tablesByName.put(key, new TableInfo(name, tableType, comment)); + } + } + + private static boolean objectTypesInclude(List objectTypes, String expectedType) { + if (objectTypes == null || objectTypes.isEmpty()) { + return true; + } + for (String objectType : objectTypes) { + if (expectedType.equals(normalizeObjectType(objectType))) { + return true; + } + } + return false; + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjectTypes(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() + || constraints.objectTypeAllowed("PROCEDURE") + || constraints.objectTypeAllowed("FUNCTION"); + } + + private static void appendDamengObjectTypePredicate( + StringBuilder sql, + List args, + MetadataListConstraints constraints, + boolean tableOnly + ) { + List objectTypes = tableOnly ? damengTableObjectTypes(constraints) : damengObjectTypes(constraints); + if (objectTypes.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND o.OBJECT_TYPE IN (").append(placeholders(objectTypes.size())).append(")"); + args.addAll(objectTypes); + } + + private static List damengTableObjectTypes(MetadataListConstraints constraints) { + List result = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + result.add("TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + result.add("VIEW"); + } + if (constraints.tableTypeAllowed("MATERIALIZED_VIEW")) { + result.add("MATERIALIZED VIEW"); + } + return result; + } + + private static List damengObjectTypes(MetadataListConstraints constraints) { + List result = damengTableObjectTypes(constraints); + if (constraints.objectTypeAllowed("PROCEDURE")) { + result.add("PROCEDURE"); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + result.add("FUNCTION"); + } + return result; + } + + private static void appendNameFilter(StringBuilder sql, List args, String column, MetadataListConstraints constraints) { + if (!constraints.hasFilter()) { + return; + } + sql.append(" AND UPPER(").append(column).append(") LIKE ? ESCAPE '\\\\'"); + args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + + private static void appendLimitOffset(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasLimit()) { + return; + } + sql.append(" LIMIT ?"); + args.add(constraints.getLimit()); + if (constraints.hasOffset()) { + sql.append(" OFFSET ?"); + args.add(constraints.getOffset()); + } + } + + private static String placeholders(int count) { + return String.join(", ", java.util.Collections.nCopies(count, "?")); + } + + private static void bind(PreparedStatement stmt, List args) throws Exception { + for (int index = 0; index < args.size(); index += 1) { + Object arg = args.get(index); + if (arg instanceof Integer) { + stmt.setInt(index + 1, (Integer) arg); + } else if (arg == null) { + stmt.setObject(index + 1, null); + } else { + stmt.setString(index + 1, String.valueOf(arg)); + } + } + } + + static final class MetadataQuery { + private final String sql; + private final List args; + + MetadataQuery(String sql, List args) { + this.sql = sql; + this.args = args; + } + + String sql() { + return sql; + } + + List args() { + return args; + } + } + + private static String normalizeObjectType(String value) { + String upper = value == null ? "" : value.trim().toUpperCase(Locale.ROOT).replace(' ', '_').replace('-', '_'); + if (upper.contains("MATERIALIZED") && upper.contains("VIEW")) { + return "MATERIALIZED_VIEW"; + } + if (upper.contains("VIEW")) { + return "VIEW"; + } + if (upper.contains("TABLE")) { + return "TABLE"; + } + return upper; + } + + private static String metadataNameKey(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + String sql = """ + SELECT OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS + WHERE OWNER = ? AND OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY CASE OBJECT_TYPE WHEN 'PROCEDURE' THEN 0 ELSE 1 END, OBJECT_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + return queryConstrainedObjects(schema, normalized); + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + if (!includesSupportedObjectTypes(constraints)) { + return List.of(); + } + try { + return unchecked(() -> { + MetadataQuery query = buildConstrainedObjectsQuery(schema, constraints); + List result = new ArrayList<>(); + try (PreparedStatement stmt = requireConnected().prepareStatement(query.sql())) { + bind(stmt, query.args()); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo( + rs.getString("OBJECT_NAME"), + normalizeObjectType(rs.getString("OBJECT_TYPE")), + schema, + rs.getString("COMMENTS") + )); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } catch (RuntimeException e) { + // Keep restricted/older Dameng catalogs usable even if SQL pushdown + // is unavailable for a specific connection. + return constraints.filterObjects(listObjects(schema)); + } + } + + static MetadataQuery buildConstrainedObjectsQuery(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder(""" + SELECT o.OBJECT_NAME, + CASE WHEN o.OBJECT_TYPE = 'MATERIALIZED VIEW' THEN 'MATERIALIZED_VIEW' ELSE o.OBJECT_TYPE END AS OBJECT_TYPE, + c.COMMENTS + FROM ALL_OBJECTS o + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME + WHERE o.OWNER = ? + """.stripIndent().trim()); + args.add(schema); + appendDamengObjectTypePredicate(sql, args, normalized, false); + sql.append(" AND (o.OBJECT_TYPE <> 'TABLE' OR o.OBJECT_NAME NOT LIKE 'MTAB$_%')"); + appendNameFilter(sql, args, "o.OBJECT_NAME", normalized); + sql.append(" ORDER BY CASE o.OBJECT_TYPE") + .append(" WHEN 'TABLE' THEN 0") + .append(" WHEN 'VIEW' THEN 1") + .append(" WHEN 'MATERIALIZED VIEW' THEN 2") + .append(" WHEN 'PROCEDURE' THEN 3") + .append(" WHEN 'FUNCTION' THEN 4") + .append(" ELSE 9 END, o.OBJECT_NAME"); + appendLimitOffset(sql, args, normalized); + return new MetadataQuery(sql.toString(), args); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String dbmsType = switch (objectType.toUpperCase(Locale.ROOT)) { + case "VIEW" -> "VIEW"; + case "MATERIALIZED_VIEW", "MATERIALIZED VIEW" -> "MATERIALIZED_VIEW"; + case "PROCEDURE" -> "PROCEDURE"; + case "FUNCTION" -> "FUNCTION"; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + String source; + String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, dbmsType); + stmt.setString(2, name); + stmt.setString(3, schema); + try (ResultSet rs = stmt.executeQuery()) { + source = rs.next() ? coalesce(readTextColumn(rs, 1)) : ""; + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public String getTableDdl(String schema, String table) { + return unchecked(() -> { + String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, "TABLE"); + stmt.setString(2, table); + stmt.setString(3, schema); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String ddl = appendTableAndColumnComments(coalesce(readTextColumn(rs, 1)), schema, table); + return appendIndependentIndexDdl(ddl, schema, table); + } + } + } + throw new IllegalArgumentException("Table not found: " + schema + "." + table); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Set pkColumns = new java.util.HashSet<>(); + String pkSql = """ + SELECT cols.COLUMN_NAME FROM ALL_CONS_COLUMNS cols + JOIN ALL_CONSTRAINTS cons ON cols.CONSTRAINT_NAME = cons.CONSTRAINT_NAME AND cols.OWNER = cons.OWNER + WHERE cons.CONSTRAINT_TYPE = 'P' AND cons.OWNER = ? AND cons.TABLE_NAME = ? + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(pkSql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + pkColumns.add(rs.getString(1)); + } + } + } + + Set identityColumns = identityColumns(schema, table); + List result = new ArrayList<>(); + // DATA_DEFAULT is a LONG column — it must be selected first and read first + // in JDBC, otherwise the data is truncated. + String colSql = """ + SELECT c.DATA_DEFAULT, + c.COLUMN_NAME, + c.DATA_TYPE, + c.NULLABLE, + c.DATA_PRECISION, + c.DATA_SCALE, + c.DATA_LENGTH, + c.CHAR_LENGTH, + cc.COMMENTS + FROM ALL_TAB_COLUMNS c + LEFT JOIN ALL_COL_COMMENTS cc + ON cc.OWNER = c.OWNER + AND cc.TABLE_NAME = c.TABLE_NAME + AND cc.COLUMN_NAME = c.COLUMN_NAME + WHERE c.OWNER = ? AND c.TABLE_NAME = ? + ORDER BY c.COLUMN_ID + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(colSql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + // DATA_DEFAULT is a LONG — must be read first, before all other columns. + String dataDefault = readLongColumn(rs, "DATA_DEFAULT"); + String name = rs.getString("COLUMN_NAME"); + String baseType = rs.getString("DATA_TYPE"); + Integer numPrec = intObject(rs, "DATA_PRECISION"); + Integer numScale = intObject(rs, "DATA_SCALE"); + Integer dataLen = intObject(rs, "DATA_LENGTH"); + Integer charLen = intObject(rs, "CHAR_LENGTH"); + String dataType = formatDataType(baseType, numPrec, numScale, dataLen, charLen); + + result.add(new ColumnInfo( + name, + dataType, + "Y".equals(rs.getString("NULLABLE")), + dataDefault, + pkColumns.contains(name), + identityColumns.contains(name) ? "identity" : null, + rs.getString("COMMENTS"), + numPrec, + numScale, + charLen + )); + } + } + } + fillMissingColumnComments(schema, table, result); + return result; + }); + } + + private Set identityColumns(String schema, String table) { + Set result = new java.util.HashSet<>(); + String sql = """ + SELECT c.NAME + FROM SYS.SYSCOLUMNS c + JOIN SYS.SYSOBJECTS t ON c.ID = t.ID + JOIN SYS.SYSOBJECTS s ON t.SCHID = s.ID + WHERE s.NAME = ? AND t.NAME = ? AND (c.INFO2 & 0x01) = 0x01 + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String column = rs.getString(1); + if (notBlank(column)) { + result.add(column); + } + } + } + } catch (Exception ignored) { + // Some Dameng versions or users do not expose SYS.SYSCOLUMNS. + } + return result; + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = """ + SELECT i.INDEX_NAME, + LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS, + i.UNIQUENESS, + CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK, + i.INDEX_TYPE + FROM ALL_INDEXES i + JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER + LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER + AND c.CONSTRAINT_TYPE = 'P' + WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ? + GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE + ORDER BY i.INDEX_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new IndexInfo( + rs.getString(1), + splitNonEmpty(coalesce(rs.getString(2)), ","), + "UNIQUE".equals(rs.getString(3)), + "1".equals(rs.getString(4)), + null, + rs.getString(5), + null, + null + )); + } + } + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = """ + SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME + FROM ALL_CONSTRAINTS c + JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER + JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER + JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER + WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = ? AND c.TABLE_NAME = ? + ORDER BY c.CONSTRAINT_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString(1), + rs.getString(2), + rs.getString(3), + rs.getString(4) + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = """ + SELECT TRIGGER_NAME, TRIGGERING_EVENT, '' AS TRIGGER_TYPE + FROM ALL_TRIGGERS + WHERE OWNER = ? AND TABLE_NAME = ? + ORDER BY TRIGGER_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo(rs.getString(1), rs.getString(2), rs.getString(3))); + } + } + } + return result; + }); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::stringResultValue + ); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.executePage( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + this::stringResultValue + ); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.startTableRead( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + this::stringResultValue + ); + } + + @Override + public String setSchemaSQL(String schema) { + return "SET SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private Object stringResultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = switch (sqlType) { + case Types.BIGINT -> rs.getLong(index); + case Types.INTEGER, Types.SMALLINT, Types.TINYINT -> rs.getInt(index); + case Types.FLOAT, Types.REAL -> rs.getFloat(index); + case Types.DOUBLE -> rs.getDouble(index); + case Types.DECIMAL, Types.NUMERIC -> rs.getBigDecimal(index); + case Types.BOOLEAN, Types.BIT -> rs.getBoolean(index); + case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, + Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR, + Types.CLOB, Types.NCLOB -> rs.getString(index); + case Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY, + Types.BLOB -> bytesToHex(rs.getBytes(index)); + case Types.SQLXML -> sqlXmlToString(rs.getSQLXML(index)); + default -> normalizeResultValue(rs.getObject(index)); + }; + return rs.wasNull() ? null : value; + }); + } + + private static Object normalizeResultValue(Object value) { + if (value == null) return null; + if (value instanceof Clob) { + Clob clob = (Clob) value; + return unchecked(() -> clob.getSubString(1, Math.toIntExact(clob.length()))); + } + if (value instanceof Blob) { + Blob blob = (Blob) value; + return unchecked(() -> bytesToHex(blob.getBytes(1, Math.toIntExact(blob.length())))); + } + if (value instanceof SQLXML) { + SQLXML sqlxml = (SQLXML) value; + return unchecked(sqlxml::getString); + } + if (value instanceof byte[]) { + return bytesToHex((byte[]) value); + } + return value instanceof Number || value instanceof Boolean ? value : value.toString(); + } + + private static String sqlXmlToString(SQLXML value) { + return value == null ? null : unchecked(value::getString); + } + + private static String readTextColumn(ResultSet rs, int columnIndex) throws Exception { + try (Reader reader = rs.getCharacterStream(columnIndex)) { + String value = readAll(reader); + if (value != null) { + return value; + } + } catch (Exception ignored) { + } + try { + Clob clob = rs.getClob(columnIndex); + if (clob != null) { + return clob.getSubString(1, Math.toIntExact(clob.length())); + } + } catch (Exception ignored) { + } + return rs.getString(columnIndex); + } + + private static String bytesToHex(byte[] bytes) { + if (bytes == null) { + return null; + } + StringBuilder result = new StringBuilder(bytes.length * 2 + 2); + result.append("0x"); + for (byte b : bytes) { + result.append(Character.forDigit((b >> 4) & 0xF, 16)); + result.append(Character.forDigit(b & 0xF, 16)); + } + return result.toString(); + } + + private static String buildUrl(ConnectParams params) { + String database = params.getDatabase() == null ? "" : params.getDatabase().trim(); + String suffix = database.isEmpty() ? "" : "/" + database; + return "jdbc:dm://" + params.getHost() + ":" + params.getPort() + suffix; + } + + private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen) { + return switch (base.toUpperCase(Locale.ROOT)) { + case "VARCHAR2", "NVARCHAR2", "VARCHAR", "CHAR", "NCHAR" -> { + Integer length = charLen != null ? charLen : dataLen; + yield length != null ? base + "(" + length + ")" : base; + } + case "NUMBER", "NUMERIC", "DECIMAL" -> { + if (numPrec != null && numScale != null && numScale > 0) { + yield base + "(" + numPrec + "," + numScale + ")"; + } + yield numPrec != null && numPrec > 0 ? base + "(" + numPrec + ")" : base; + } + case "RAW" -> dataLen != null ? "RAW(" + dataLen + ")" : "RAW"; + default -> base; + }; + } + + private static Integer intObject(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value == null ? null : ((Number) value).intValue(); + } + + // DATA_DEFAULT is stored as a LONG column in Oracle/Dameng. JDBC requires LONG + // columns to be read before other columns. We also try getCharacterStream as a + // fallback because some drivers don't support getString on LONG columns. + private static String readLongColumn(ResultSet rs, String column) throws Exception { + try { + String value = rs.getString(column); + if (value != null) { + return value; + } + } catch (Exception ignored) { + } + try (Reader reader = rs.getCharacterStream(column)) { + return readAll(reader); + } + } + + private static String readAll(Reader reader) throws Exception { + if (reader == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + char[] buf = new char[4096]; + int n; + while ((n = reader.read(buf)) != -1) { + sb.append(buf, 0, n); + } + return sb.toString(); + } + + private static List splitNonEmpty(String value, String delimiter) { + List result = new ArrayList<>(); + Arrays.stream(value.split(delimiter)) + .filter(part -> !part.isEmpty()) + .forEach(result::add); + return result; + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + private void fillMissingColumnComments(String schema, String table, List columns) { + if (columns.stream().noneMatch(column -> !notBlank(column.getComment()))) { + return; + } + Map comments = new HashMap<>(); + queryColumnComments( + comments, + "SELECT COLUMN_NAME, COMMENTS FROM USER_COL_COMMENTS WHERE TABLE_NAME = ?", + table + ); + queryColumnComments( + comments, + "SELECT COLNAME, COMMENT$ FROM SYS.SYSCOLUMNCOMMENTS WHERE SCHNAME = ? AND TVNAME = ?", + schema, + table + ); + queryColumnComments( + comments, + "SELECT COLUMN_NAME, COMMENTS FROM ALL_COL_COMMENTS WHERE UPPER(OWNER) = UPPER(?) AND UPPER(TABLE_NAME) = UPPER(?)", + schema, + table + ); + for (ColumnInfo column : columns) { + if (notBlank(column.getComment())) { + continue; + } + String comment = comments.get(column.getName().toUpperCase(Locale.ROOT)); + if (notBlank(comment)) { + column.setComment(comment); + } + } + } + + private void queryColumnComments(Map comments, String sql, String... params) { + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + for (int i = 0; i < params.length; i++) { + stmt.setString(i + 1, params[i]); + } + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String column = rs.getString(1); + String comment = rs.getString(2); + if (notBlank(column) && notBlank(comment)) { + comments.putIfAbsent(column.toUpperCase(Locale.ROOT), comment); + } + } + } + } catch (Exception ignored) { + // Some Dameng versions or users do not expose every comment view. + } + } + + private String appendTableAndColumnComments(String ddl, String schema, String table) throws Exception { + StringBuilder result = new StringBuilder(ddl == null ? "" : ddl.trim()); + String tableRef = qualifiedName(schema, table); + String tableComment = tableComment(schema, table); + if (notBlank(tableComment) && !containsCommentOnTable(result.toString(), schema, table)) { + appendCommentStatement(result, "COMMENT ON TABLE " + tableRef + " IS '" + sqlStringBody(tableComment) + "';"); + } + for (ColumnInfo column : getColumns(schema, table)) { + if (!notBlank(column.getComment()) || containsCommentOnColumn(result.toString(), schema, table, column.getName())) { + continue; + } + appendCommentStatement(result, "COMMENT ON COLUMN " + tableRef + "." + JdbcIdentifiers.INSTANCE.doubleQuote(column.getName()) + " IS '" + sqlStringBody(column.getComment()) + "';"); + } + return result.toString(); + } + + private String appendIndependentIndexDdl(String ddl, String schema, String table) throws Exception { + StringBuilder result = new StringBuilder(ddl == null ? "" : ddl.trim()); + for (IndexInfo index : independentIndexes(schema, table)) { + String indexName = index.getName(); + if (isInternalIndexMetadata(index) || index.getColumns().isEmpty()) { + continue; + } + if (containsCreateIndex(result.toString(), schema, indexName)) { + continue; + } + appendDdlStatement(result, indexDdl(schema, table, index)); + } + return result.toString(); + } + + private List independentIndexes(String schema, String table) throws Exception { + List result = new ArrayList<>(); + // Primary-key and unique-constraint backing indexes are already represented in table DDL. + String sql = """ + SELECT i.INDEX_NAME, + LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS, + i.UNIQUENESS, + i.INDEX_TYPE + FROM ALL_INDEXES i + JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER + WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ? + AND NOT EXISTS ( + SELECT 1 + FROM ALL_CONSTRAINTS c + WHERE c.OWNER = i.TABLE_OWNER + AND c.TABLE_NAME = i.TABLE_NAME + AND c.INDEX_NAME = i.INDEX_NAME + AND c.CONSTRAINT_TYPE IN ('P', 'U') + ) + GROUP BY i.INDEX_NAME, i.UNIQUENESS, i.INDEX_TYPE + ORDER BY i.INDEX_NAME + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String indexName = rs.getString(1); + if (notBlank(indexName)) { + result.add(new IndexInfo( + indexName, + splitNonEmpty(coalesce(rs.getString(2)), ","), + "UNIQUE".equals(rs.getString(3)), + false, + null, + rs.getString(4), + null, + null + )); + } + } + } + } + return result; + } + + private static String indexDdl(String schema, String table, IndexInfo index) { + StringBuilder ddl = new StringBuilder("CREATE "); + if (index.getIs_unique()) { + ddl.append("UNIQUE "); + } + ddl.append("INDEX ") + .append(qualifiedName(schema, index.getName())) + .append(" ON ") + .append(qualifiedName(schema, table)) + .append(" ("); + for (int i = 0; i < index.getColumns().size(); i++) { + if (i > 0) { + ddl.append(", "); + } + ddl.append(JdbcIdentifiers.INSTANCE.doubleQuote(index.getColumns().get(i))); + } + ddl.append(");"); + return ddl.toString(); + } + + private static boolean isInternalIndexMetadata(IndexInfo index) { + String indexType = coalesce(index.getIndex_type()).toUpperCase(Locale.ROOT); + return indexType.contains("INNER") || indexType.contains("INTERNAL"); + } + + private String tableComment(String schema, String table) throws Exception { + String sql = """ + SELECT COMMENTS + FROM ALL_TAB_COMMENTS + WHERE OWNER = ? AND TABLE_NAME = ? + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + + private static void appendCommentStatement(StringBuilder ddl, String statement) { + appendDdlStatement(ddl, statement); + } + + private static void appendDdlStatement(StringBuilder ddl, String statement) { + if (!ddl.isEmpty()) { + if (ddl.charAt(ddl.length() - 1) != '\n') { + ddl.append("\n"); + } + ddl.append("\n"); + } + ddl.append(statement); + } + + private static String ensureStatementTerminator(String statement) { + String trimmed = coalesce(statement).trim(); + if (trimmed.isEmpty() || trimmed.endsWith(";")) { + return trimmed; + } + return trimmed + ";"; + } + + private static boolean containsCommentOnTable(String ddl, String schema, String table) { + return normalizedDdl(ddl).contains("COMMENT ON TABLE " + normalizedQualifiedName(schema, table)); + } + + private static boolean containsCommentOnColumn(String ddl, String schema, String table, String column) { + return normalizedDdl(ddl).contains("COMMENT ON COLUMN " + normalizedQualifiedName(schema, table) + "." + normalizedIdentifier(column)); + } + + private static boolean containsCreateIndex(String ddl, String schema, String indexName) { + String normalized = normalizedDdl(ddl); + return normalized.contains(" INDEX " + normalizedQualifiedName(schema, indexName) + " ") + || normalized.contains(" INDEX " + normalizedIdentifier(indexName) + " "); + } + + private static String qualifiedName(String schema, String name) { + if (!notBlank(schema)) { + return JdbcIdentifiers.INSTANCE.doubleQuote(name); + } + return JdbcIdentifiers.INSTANCE.doubleQuote(schema) + "." + JdbcIdentifiers.INSTANCE.doubleQuote(name); + } + + private static String normalizedQualifiedName(String schema, String name) { + if (!notBlank(schema)) { + return normalizedIdentifier(name); + } + return normalizedIdentifier(schema) + "." + normalizedIdentifier(name); + } + + private static String normalizedIdentifier(String value) { + return JdbcIdentifiers.INSTANCE.doubleQuote(value).toUpperCase(Locale.ROOT); + } + + private static String normalizedDdl(String ddl) { + return coalesce(ddl).toUpperCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + private static boolean notBlank(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static String sqlStringBody(String value) { + return value.replace("'", "''"); + } + + @Override + public String getExplainInfo(String sql, String database, String schema, int timeoutSecs, String mode) { + return unchecked(() -> { + Connection conn = requireConnected(); + boolean autotrace = "autotrace".equalsIgnoreCase(mode); + String planText = null; + + if (autotrace) { + boolean monitorEnabled = false; + try (Statement s = conn.createStatement()) { + s.execute("SF_SET_SESSION_PARA_VALUE('MONITOR_SQL_EXEC', 1)"); + monitorEnabled = true; + } catch (Exception ignored) {} + + try (Statement stmt = conn.createStatement()) { + if (timeoutSecs >= 0) { + try { stmt.setQueryTimeout(timeoutSecs); } catch (Exception ignored) {} + } + boolean hasResultSet = stmt.execute(sql); + if (hasResultSet) { + try (ResultSet rs = stmt.getResultSet()) { + while (rs.next()) { /* consume all rows */ } + } + } + try { + Class dmConnClass = Class.forName("dm.jdbc.driver.DmdbConnection"); + if (dmConnClass.isInstance(conn)) { + Method m = dmConnClass.getMethod("getExplainInfo", Statement.class); + planText = (String) m.invoke(dmConnClass.cast(conn), stmt); + } + } catch (Exception ignored) {} + } finally { + if (monitorEnabled) { + try (Statement s = conn.createStatement()) { + s.execute("SF_SET_SESSION_PARA_VALUE('MONITOR_SQL_EXEC', 0)"); + } catch (Exception ignored) {} + } + } + } else { + try { + Class dmConnClass = Class.forName("dm.jdbc.driver.DmdbConnection"); + if (dmConnClass.isInstance(conn)) { + Method m = dmConnClass.getMethod("getExplainInfo", String.class); + planText = (String) m.invoke(dmConnClass.cast(conn), sql); + } + } catch (Exception ignored) {} + } + + if (planText == null || planText.trim().isEmpty()) { + try (Statement explainStmt = conn.createStatement(); + ResultSet rs = explainStmt.executeQuery("EXPLAIN " + sql)) { + StringBuilder sb = new StringBuilder(); + while (rs.next()) { + sb.append(rs.getString(1)).append("\n"); + } + planText = sb.toString().trim(); + } catch (Exception ignored) {} + } + return planText != null ? planText : ""; + }); + } + + public static void main(String[] args) { + new JsonRpcServer(new DamengAgent()).run(); + } +} diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java new file mode 100644 index 0000000..2358890 --- /dev/null +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentMetadataTest.java @@ -0,0 +1,596 @@ +package com.dbx.agent.dameng; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.io.StringReader; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +class DamengAgentMetadataTest { + @Test + void usesColumnCommentsMetadataQuery() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.getColumns("APP", "USERS"); + + String columnsSql = JdbcMetadataSqlFake.statements.stream() + .filter(sql -> sql.contains("ALL_TAB_COLUMNS")) + .findFirst() + .orElseThrow(); + Assertions.assertTrue(columnsSql.contains("LEFT JOIN ALL_COL_COMMENTS"), columnsSql); + } + + @Test + void usesTableCommentsMetadataQuery() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("APP"); + + String tablesSql = JdbcMetadataSqlFake.statements.stream() + .filter(sql -> sql.contains("ALL_TAB_COMMENTS")) + .findFirst() + .orElseThrow(); + String allTablesSql = String.join("\n", JdbcMetadataSqlFake.statements); + Assertions.assertTrue(tablesSql.contains("COMMENTS"), tablesSql); + Assertions.assertTrue(allTablesSql.contains("ALL_OBJECTS"), allTablesSql); + Assertions.assertTrue(allTablesSql.contains("USER_MVIEWS"), allTablesSql); + Assertions.assertFalse(allTablesSql.contains("ALL_MVIEWS"), allTablesSql); + Assertions.assertFalse(tablesSql.contains("ALL_TABLES"), tablesSql); + Assertions.assertFalse(tablesSql.contains("ALL_VIEWS"), tablesSql); + } + + @Test + void mapsTableCommentFromMetadata() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection()); + + List tables = agent.listTables("APP"); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("USERS", tables.get(0).getName()); + Assertions.assertEquals("用户示例表", tables.get(0).getComment()); + } + + @Test + void mapsTableCommentToObjectMetadata() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection()); + + List objects = agent.listObjects("APP"); + + Assertions.assertEquals(1, objects.size()); + Assertions.assertEquals("USERS", objects.get(0).getName()); + Assertions.assertEquals("用户示例表", objects.get(0).getComment()); + } + + @Test + void listSchemasIncludesSchemaObjectsWithoutChildren() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, metadataConnection("id comment", null, false, List.of(), sqls, "")); + + List schemas = agent.listSchemas(); + + Assertions.assertEquals(List.of("APP", "EMPTY_SCHEMA"), schemas); + Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSOBJECTS") && sql.contains("TYPE$ = 'SCH'")), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_USERS")), String.join("\n", sqls)); + Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_OBJECTS")), String.join("\n", sqls)); + } + + @Test + void mapsMaterializedViewsFromMetadata() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection("id comment", null, true)); + + List tables = agent.listTables("APP"); + List objects = agent.listObjects("APP"); + + Assertions.assertTrue(tables.stream().anyMatch(table -> + "USER_SUMMARY_MV".equals(table.getName()) && "MATERIALIZED_VIEW".equals(table.getTable_type()) + )); + Assertions.assertFalse(tables.stream().anyMatch(table -> + "USER_SUMMARY_MV".equals(table.getName()) && "VIEW".equals(table.getTable_type()) + )); + Assertions.assertTrue(objects.stream().anyMatch(object -> + "USER_SUMMARY_MV".equals(object.getName()) && "MATERIALIZED_VIEW".equals(object.getObject_type()) + )); + } + + @Test + void filtersTableListingByRequestedObjectTypes() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection("id comment", null, true)); + setConnectedUsername(agent, "APP"); + + List views = agent.listTables("APP", List.of("VIEW")); + List materializedViews = agent.listTables("APP", List.of("MATERIALIZED_VIEW")); + + Assertions.assertTrue(views.stream().noneMatch(table -> "MATERIALIZED_VIEW".equals(table.getTable_type()))); + Assertions.assertTrue(views.stream().noneMatch(table -> "USER_SUMMARY_MV".equals(table.getName()))); + Assertions.assertEquals(List.of("USER_SUMMARY_MV"), materializedViews.stream().map(TableInfo::getName).toList()); + } + + @Test + void readsMaterializedViewSourceWithDbmsMetadataType() { + DamengAgent agent = new DamengAgent(); + List params = new ArrayList<>(); + TestSupport.setPrivateConnection( + agent, + objectSourceConnection(params, "CREATE MATERIALIZED VIEW \"APP\".\"USER_SUMMARY_MV\" AS SELECT 1 AS ID FROM DUAL") + ); + + ObjectSource source = agent.getObjectSource("APP", "USER_SUMMARY_MV", "MATERIALIZED_VIEW"); + + Assertions.assertEquals(List.of("MATERIALIZED_VIEW", "USER_SUMMARY_MV", "APP"), params); + Assertions.assertEquals("MATERIALIZED_VIEW", source.getObject_type()); + Assertions.assertTrue(source.getSource().contains("CREATE MATERIALIZED VIEW"), source.getSource()); + } + + @Test + void triggerMetadataDoesNotRequireOracleTriggerTypeColumn() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTriggers("APP", "USERS"); + + String triggersSql = JdbcMetadataSqlFake.statements.stream() + .filter(sql -> sql.contains("ALL_TRIGGERS")) + .findFirst() + .orElseThrow(); + Assertions.assertFalse(triggersSql.contains("TRIGGERING_EVENT, TRIGGER_TYPE"), triggersSql); + Assertions.assertTrue(triggersSql.contains("'' AS TRIGGER_TYPE"), triggersSql); + } + + @Test + void mapsColumnCommentFromMetadata() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection()); + + List columns = agent.getColumns("APP", "USERS"); + + Assertions.assertEquals(1, columns.size()); + Assertions.assertEquals("id comment", columns.get(0).getComment()); + } + + @Test + void mapsColumnCommentFromFallbackMetadataViews() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection(null, "fallback id comment")); + + List columns = agent.getColumns("APP", "USERS"); + + Assertions.assertEquals(1, columns.size()); + Assertions.assertEquals("fallback id comment", columns.get(0).getComment()); + } + + @Test + void mapsIdentityColumnExtraFromSysColumns() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection()); + + List columns = agent.getColumns("APP", "USERS"); + + Assertions.assertEquals(1, columns.size()); + Assertions.assertEquals("identity", columns.get(0).getExtra()); + } + + @Test + void appendsTableAndColumnCommentsToTableDdl() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection(agent, metadataConnection()); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl); + Assertions.assertTrue(ddl.contains("COMMENT ON TABLE \"APP\".\"USERS\" IS '用户示例表';"), ddl); + Assertions.assertTrue(ddl.contains("COMMENT ON COLUMN \"APP\".\"USERS\".\"ID\" IS 'id comment';"), ddl); + } + + @Test + void readsFullTableDdlFromCharacterStreamWhenGetStringIsTruncated() { + DamengAgent agent = new DamengAgent(); + String fullDdl = "CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER,\n \"PAYLOAD\" VARCHAR2(2000)\n);\n-- " + + "x".repeat(5000) + + "\n-- DBX_FULL_DDL_END"; + TestSupport.setPrivateConnection(agent, metadataConnection( + "id comment", + null, + false, + List.of(), + null, + fullDdl + )); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue(ddl.contains("DBX_FULL_DDL_END"), ddl); + } + + @Test + void appendsIndependentIndexesToTableDdl() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, metadataConnectionWithIndexes(sqls)); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue( + ddl.contains("CREATE INDEX \"APP\".\"IDX_USERS_NAME\" ON \"APP\".\"USERS\" (\"NAME\");"), + ddl + ); + Assertions.assertTrue( + ddl.contains("CREATE UNIQUE INDEX \"APP\".\"UX_USERS_EMAIL\" ON \"APP\".\"USERS\" (\"EMAIL\");"), + ddl + ); + Assertions.assertFalse(ddl.contains("PK_USERS"), ddl); + String indexSql = sqls.stream().filter(sql -> sql.contains("ALL_INDEXES")).findFirst().orElseThrow(); + Assertions.assertTrue(indexSql.contains("CONSTRAINT_TYPE IN ('P', 'U')"), indexSql); + long dbmsMetadataCalls = sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count(); + Assertions.assertEquals(1, dbmsMetadataCalls); + } + + @Test + void skipsDamengInternalIndexesWhenAppendingTableDdl() { + DamengAgent agent = new DamengAgent(); + List sqls = new ArrayList<>(); + TestSupport.setPrivateConnection(agent, metadataConnection( + "id comment", + null, + false, + List.of( + indexRow("IDX_USERS_NAME", "NAME", "NONUNIQUE", "NORMAL"), + indexRow("SYS_INTERNAL_DDL", "ID", "NONUNIQUE", "INNER CLUSTER INDEX") + ), + sqls + )); + + String ddl = agent.getTableDdl("APP", "USERS"); + + Assertions.assertTrue( + ddl.contains("CREATE INDEX \"APP\".\"IDX_USERS_NAME\" ON \"APP\".\"USERS\" (\"NAME\");"), + ddl + ); + Assertions.assertFalse(ddl.contains("SYS_INTERNAL_DDL"), ddl); + Assertions.assertFalse(ddl.contains("INNER CLUSTER INDEX"), ddl); + long dbmsMetadataCalls = sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count(); + Assertions.assertEquals(1, dbmsMetadataCalls); + } + + private static Connection metadataConnection() { + return metadataConnection("id comment", null); + } + + private static Connection metadataConnection(String allColumnComment, String fallbackColumnComment) { + return metadataConnection(allColumnComment, fallbackColumnComment, false); + } + + private static Connection metadataConnection(String allColumnComment, String fallbackColumnComment, boolean includeMaterializedView) { + return metadataConnection(allColumnComment, fallbackColumnComment, includeMaterializedView, List.of(), null); + } + + private static Connection metadataConnectionWithIndexes(List sqls) { + return metadataConnection( + "id comment", + null, + false, + List.of( + indexRow("IDX_USERS_NAME", "NAME", "NONUNIQUE", "NORMAL"), + indexRow("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "NORMAL") + ), + sqls + ); + } + + private static Connection metadataConnection( + String allColumnComment, + String fallbackColumnComment, + boolean includeMaterializedView, + List> independentIndexes, + List sqls + ) { + return metadataConnection(allColumnComment, fallbackColumnComment, includeMaterializedView, independentIndexes, sqls, "CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);"); + } + + private static Connection metadataConnection( + String allColumnComment, + String fallbackColumnComment, + boolean includeMaterializedView, + List> independentIndexes, + List sqls, + String dbmsMetadataDdl + ) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + String sql = (String) args[0]; + if (sqls != null) { + sqls.add(sql); + } + if (sql.contains("DBMS_METADATA.GET_DDL")) { + return dbmsMetadataStatement(dbmsMetadataDdl); + } + if (sql.contains("SYS.SYSOBJECTS") && sql.contains("TYPE$ = 'SCH'")) { + return metadataStatement(List.of(List.of("APP"), List.of("EMPTY_SCHEMA"))); + } + if (sql.contains("ALL_CONS_COLUMNS")) { + return metadataStatement(List.of(List.of("ID"))); + } + if (sql.contains("SYS.SYSCOLUMNS")) { + return metadataStatement(List.of(List.of("ID"))); + } + if (sql.startsWith("SELECT COMMENTS")) { + return metadataStatement(List.of(List.of("用户示例表"))); + } + if (sql.contains("USER_COL_COMMENTS")) { + return metadataStatement(fallbackColumnComment == null ? List.of() : List.of(List.of("ID", fallbackColumnComment))); + } + if (sql.contains("SYSCOLUMNCOMMENTS")) { + return metadataStatement(List.of()); + } + if (sql.contains("ALL_OBJECTS") && sql.contains("OBJECT_TYPE = 'TABLE'")) { + return metadataStatement(List.of(List.of("USERS", "用户示例表"))); + } + if (sql.contains("ALL_OBJECTS") && sql.contains("OBJECT_TYPE = 'VIEW'")) { + return metadataStatement( + includeMaterializedView + ? List.of(Arrays.asList("USER_SUMMARY_MV", "mv comment")) + : List.of() + ); + } + if (sql.contains("USER_MVIEWS") && sql.contains("ALL_OBJECTS")) { + return metadataStatement( + includeMaterializedView ? List.of(Arrays.asList("USER_SUMMARY_MV", "mv comment")) : List.of() + ); + } + if (sql.contains("ALL_OBJECTS")) { + return metadataStatement(List.of()); + } + if (sql.contains("ALL_INDEXES")) { + return metadataStatement(independentIndexes); + } + if (sql.contains("ALL_TAB_COMMENTS")) { + List> rows = new ArrayList<>(); + rows.add(List.of("USERS", "TABLE", "用户示例表")); + if (includeMaterializedView) { + rows.add(Arrays.asList("USER_SUMMARY_MV", "VIEW", "mv comment")); + } + return metadataStatement(rows); + } + if (sql.contains("USER_MVIEWS")) { + return metadataStatement( + includeMaterializedView + ? List.of(List.of("USER_SUMMARY_MV")) + : List.of() + ); + } + if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) { + return metadataStatement(List.of()); + } + if (sql.contains("ALL_TAB_COLUMNS")) { + return metadataStatement(List.of(Arrays.asList( + "ID", + "NUMBER", + "N", + Integer.valueOf(10), + Integer.valueOf(0), + Integer.valueOf(22), + Integer.valueOf(10), + allColumnComment + ))); + } + } + if ("close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static List indexRow(String name, String columns, String uniqueness, String indexType) { + return List.of(name, columns, uniqueness, indexType); + } + + private static PreparedStatement dbmsMetadataStatement(String ddl) { + List params = new ArrayList<>(); + return proxy(PreparedStatement.class, (method, args) -> { + String name = method.getName(); + if ("executeQuery".equals(name)) { + String objectType = params.isEmpty() ? "" : params.get(0); + if ("INDEX".equals(objectType)) { + throw new AssertionError("Dameng table DDL should generate index DDL from metadata"); + } + return metadataResultSet(List.of(List.of(new LongText(ddl, ddl.substring(0, Math.min(ddl.length(), 64)))))); + } + if ("setString".equals(name)) { + int index = ((Integer) args[0]) - 1; + while (params.size() <= index) { + params.add(""); + } + params.set(index, String.valueOf(args[1])); + return null; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static void setConnectedUsername(DamengAgent agent, String username) { + try { + Field field = DamengAgent.class.getDeclaredField("connectedUsername"); + field.setAccessible(true); + field.set(agent, username); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to set connected username", e); + } + } + + private static Connection objectSourceConnection(List params, String source) { + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + return metadataStatement(List.of(List.of(source)), params); + } + if ("close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static PreparedStatement metadataStatement(List> rows) { + return metadataStatement(rows, null); + } + + private static PreparedStatement metadataStatement(List> rows, List params) { + return proxy(PreparedStatement.class, (method, args) -> { + String name = method.getName(); + if ("executeQuery".equals(name)) { + return metadataResultSet(rows); + } + if ("setString".equals(name)) { + if (params != null) { + params.add(String.valueOf(args[1])); + } + return null; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet metadataResultSet(List> rows) { + int[] index = {-1}; + return proxy(ResultSet.class, (method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] < rows.size(); + } + if ("getString".equals(name)) { + if (args[0] instanceof Integer columnIndex) { + Object value = rows.get(index[0]).get(columnIndex - 1); + if (value instanceof LongText longText) { + return longText.truncated; + } + return value == null ? null : value.toString(); + } + return switch (((String) args[0]).toUpperCase()) { + case "COLUMN_NAME" -> string(rows, index[0], 0); + case "DATA_TYPE" -> string(rows, index[0], 1); + case "NULLABLE" -> string(rows, index[0], 2); + case "DATA_DEFAULT" -> null; + case "COMMENTS", "COMMENT$" -> string(rows, index[0], rows.get(index[0]).size() - 1); + case "COLNAME" -> string(rows, index[0], 0); + default -> null; + }; + } + if ("getCharacterStream".equals(name) && args[0] instanceof Integer columnIndex) { + Object value = rows.get(index[0]).get(columnIndex - 1); + if (value instanceof LongText longText) { + return new StringReader(longText.full); + } + } + if ("getObject".equals(name)) { + return switch (((String) args[0]).toUpperCase()) { + case "DATA_PRECISION" -> rows.get(index[0]).get(3); + case "DATA_SCALE" -> rows.get(index[0]).get(4); + case "DATA_LENGTH" -> rows.get(index[0]).get(5); + case "CHAR_LENGTH" -> rows.get(index[0]).get(6); + default -> null; + }; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static String string(List> rows, int rowIndex, int columnIndex) { + Object value = rows.get(rowIndex).get(columnIndex); + if (value instanceof LongText longText) { + return longText.full; + } + return value == null ? null : value.toString(); + } + + private static final class LongText { + private final String full; + private final String truncated; + + private LongText(String full, String truncated) { + this.full = full; + this.truncated = truncated; + } + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args); + } + }; + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentPagingTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentPagingTest.java new file mode 100644 index 0000000..bca5f6b --- /dev/null +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentPagingTest.java @@ -0,0 +1,277 @@ +package com.dbx.agent.dameng; + +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.test.TestSupport; +import dm.jdbc.driver.DmdbTimestamp; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.sql.Types; +import javax.sql.rowset.serial.SerialBlob; +import javax.sql.rowset.serial.SerialClob; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DamengAgentPagingTest { + @Test + void executeQueryPageStringifiesTimestampValuesBeforeJsonRpcSerialization() { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection( + agent, + singleValueConnection( + "CURRENT_TIMESTAMP", + Types.TIMESTAMP, + DmdbTimestamp.valueOf("2026-05-20 12:34:56.123456789"), + null, + null + ) + ); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = handleRequest(server, """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "execute_query_page", + "params": { + "sql": "SELECT CURRENT_TIMESTAMP", + "schema": "SYS", + "pageSize": 100 + } + } + """); + + assertFalse(response.contains("\"error\""), response); + assertTrue(response.contains("2026-05-20 12:34:56.123456"), response); + } + + @Test + void executeQueryPageReadsClobValuesAsText() throws Exception { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection( + agent, + singleValueConnection( + "content", + Types.CLOB, + new SerialClob("真实文本".toCharArray()), + "真实文本", + null + ) + ); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = handleRequest(server, """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "execute_query_page", + "params": { + "sql": "SELECT content FROM sample", + "schema": "SYS", + "pageSize": 100 + } + } + """); + + assertFalse(response.contains("\"error\""), response); + assertTrue(response.contains("真实文本"), response); + assertFalse(response.contains("SerialClob"), response); + } + + @Test + void executeQueryPageFormatsBlobValuesAsHexText() throws Exception { + DamengAgent agent = new DamengAgent(); + TestSupport.setPrivateConnection( + agent, + singleValueConnection( + "payload", + Types.BLOB, + new SerialBlob(new byte[]{0x01, 0x2A, (byte) 0xFF}), + null, + new byte[]{0x01, 0x2A, (byte) 0xFF} + ) + ); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = handleRequest(server, """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "execute_query_page", + "params": { + "sql": "SELECT payload FROM sample", + "schema": "SYS", + "pageSize": 100 + } + } + """); + + assertFalse(response.contains("\"error\""), response); + assertTrue(response.contains("0x012aff"), response); + assertFalse(response.contains("SerialBlob"), response); + } + + private static Connection singleValueConnection( + String columnLabel, + int sqlType, + Object objectValue, + String stringValue, + byte[] bytesValue + ) { + Statement statement = singleValueStatement(columnLabel, sqlType, objectValue, stringValue, bytesValue); + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("createStatement".equals(name)) { + return statement; + } + if ("getAutoCommit".equals(name)) { + return true; + } + if ("setAutoCommit".equals(name) || "commit".equals(name) || "rollback".equals(name) || "close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Statement singleValueStatement( + String columnLabel, + int sqlType, + Object objectValue, + String stringValue, + byte[] bytesValue + ) { + ResultSet resultSet = singleValueResultSet(columnLabel, sqlType, objectValue, stringValue, bytesValue); + return proxy(Statement.class, (method, args) -> { + String name = method.getName(); + if ("execute".equals(name)) { + return true; + } + if ("getResultSet".equals(name)) { + return resultSet; + } + if ("getUpdateCount".equals(name)) { + return 0; + } + if ("setMaxRows".equals(name) || "setFetchSize".equals(name) || "close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet singleValueResultSet( + String columnLabel, + int sqlType, + Object objectValue, + String stringValue, + byte[] bytesValue + ) { + final int[] index = {-1}; + ResultSetMetaData metadata = singleValueMetadata(columnLabel, sqlType); + return proxy(ResultSet.class, (method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] == 0; + } + if ("getMetaData".equals(name)) { + return metadata; + } + if ("getObject".equals(name)) { + return objectValue; + } + if ("getString".equals(name)) { + return stringValue; + } + if ("getBytes".equals(name)) { + return bytesValue; + } + if ("wasNull".equals(name)) { + return false; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSetMetaData singleValueMetadata(String columnLabel, int sqlType) { + return proxy(ResultSetMetaData.class, (method, args) -> { + String name = method.getName(); + if ("getColumnCount".equals(name)) { + return 1; + } + if ("getColumnLabel".equals(name)) { + return columnLabel; + } + if ("getColumnType".equals(name)) { + return sqlType; + } + return defaultValue(method.getReturnType()); + }); + } + + private static String handleRequest(JsonRpcServer server, String request) { + try { + Method method = JsonRpcServer.class.getDeclaredMethod("handleRequest", String.class); + method.setAccessible(true); + return (String) method.invoke(server, request); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentTest.java new file mode 100644 index 0000000..afa21b7 --- /dev/null +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentTest.java @@ -0,0 +1,61 @@ +package com.dbx.agent.dameng; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DamengAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new DamengAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL SP_SAMPLE()"; + } + + @Test + void constrainedTableQueryPushesFilterTypeAndPagingToDameng() { + DamengAgent.MetadataQuery query = DamengAgent.buildConstrainedTablesQuery( + "APP", + new MetadataListConstraints("ord", 50, 100, List.of("TABLE", "VIEW")) + ); + + assertTrue(query.sql().contains("FROM ALL_OBJECTS o")); + assertTrue(query.sql().contains("o.OBJECT_TYPE IN (?, ?)")); + assertTrue(query.sql().contains("UPPER(o.OBJECT_NAME) LIKE ? ESCAPE '\\\\'")); + assertTrue(query.sql().endsWith("LIMIT ? OFFSET ?")); + assertEquals(List.of("APP", "TABLE", "VIEW", "%O%R%D%", 50, 100), query.args()); + } + + @Test + void constrainedTableQueryMapsMaterializedViewTypeToDamengCatalogName() { + DamengAgent.MetadataQuery query = DamengAgent.buildConstrainedTablesQuery( + "APP", + new MetadataListConstraints(null, 20, null, List.of("MATERIALIZED_VIEW")) + ); + + assertTrue(query.sql().contains("MATERIALIZED_VIEW")); + assertEquals(List.of("APP", "MATERIALIZED VIEW", 20), query.args()); + } + + @Test + void constrainedObjectQueryPushesRoutineOnlySearchToDameng() { + DamengAgent.MetadataQuery query = DamengAgent.buildConstrainedObjectsQuery( + "APP", + new MetadataListConstraints("sync", 20, null, List.of("PROCEDURE", "FUNCTION")) + ); + + assertTrue(query.sql().contains("o.OBJECT_TYPE IN (?, ?)")); + assertTrue(query.sql().contains("WHEN 'PROCEDURE' THEN 3")); + assertTrue(query.sql().endsWith("LIMIT ?")); + assertEquals(List.of("APP", "PROCEDURE", "FUNCTION", "%S%Y%N%C%", 20), query.args()); + } +} diff --git a/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentUrlTest.java b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentUrlTest.java new file mode 100644 index 0000000..c1d72a8 --- /dev/null +++ b/agents/drivers/dameng/src/test/java/com/dbx/agent/dameng/DamengAgentUrlTest.java @@ -0,0 +1,29 @@ +package com.dbx.agent.dameng; + +import com.dbx.agent.ConnectParams; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +class DamengAgentUrlTest { + @Test + void omitsDatabasePathWhenDatabaseIsBlank() throws Exception { + String url = invokeBuildUrl(new ConnectParams("127.0.0.1", 5236, "", "SYSDBA", "pwd", "", "", false)); + + Assertions.assertEquals("jdbc:dm://127.0.0.1:5236", url); + } + + @Test + void appendsDatabasePathWhenDatabaseIsProvided() throws Exception { + String url = invokeBuildUrl(new ConnectParams("127.0.0.1", 5236, "MAIN", "SYSDBA", "pwd", "", "", false)); + + Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url); + } + + private static String invokeBuildUrl(ConnectParams params) throws Exception { + Method method = DamengAgent.class.getDeclaredMethod("buildUrl", ConnectParams.class); + method.setAccessible(true); + return (String) method.invoke(null, params); + } +} diff --git a/agents/drivers/databend/build.gradle b/agents/drivers/databend/build.gradle new file mode 100644 index 0000000..a1db92d --- /dev/null +++ b/agents/drivers/databend/build.gradle @@ -0,0 +1,9 @@ +dependencies { + implementation 'com.databend:databend-jdbc:0.4.6' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Databend', 'Main-Class': 'com.dbx.agent.databend.DatabendAgent') + } +} diff --git a/agents/drivers/databend/src/main/java/com/dbx/agent/databend/DatabendAgent.java b/agents/drivers/databend/src/main/java/com/dbx/agent/databend/DatabendAgent.java new file mode 100644 index 0000000..48c00e4 --- /dev/null +++ b/agents/drivers/databend/src/main/java/com/dbx/agent/databend/DatabendAgent.java @@ -0,0 +1,299 @@ +package com.dbx.agent.databend; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.PreparedStatement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class DatabendAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile DATABEND_PROFILE = new JdbcAgentProfile( + "com.databend.jdbc.DatabendDriver", + "jdbc:databend://{host}:{port}/{database}", + 8000, + false, + Collections.singleton("INFORMATION_SCHEMA"), + Arrays.asList("TABLE", "VIEW", "BASE TABLE", "MATERIALIZED VIEW", "SYSTEM TABLE", "SYSTEM VIEW"), + "\"", + "USE", + true, + false, + false, + false + ); + + public DatabendAgent() { + super(DATABEND_PROFILE); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(super.listObjects(schema)); + try { + useSchema(schema); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW PROCEDURES")) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString("name"), "PROCEDURE", schema, rs.getString("comment"))); + } + } + } catch (Exception ignored) { + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + boolean includeTables = normalized.includesTableLikeTypes(); + boolean includeProcedures = includesProcedures(normalized); + if (includeProcedures && !includeTables) { + return listProcedures(schema, normalized); + } + if (includeTables && !includeProcedures) { + return super.listObjects(schema, normalized); + } + if (!includeTables && !includeProcedures) { + return List.of(); + } + return normalized.filterObjects(listObjects(schema)); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + if (!"PROCEDURE".equalsIgnoreCase(objectType)) { + return super.getObjectSource(schema, name, objectType); + } + return unchecked(() -> { + useSchema(schema); + ProcedureMetadata procedure = findProcedure(name); + if (procedure == null) { + throw new IllegalArgumentException("Procedure not found: " + name); + } + Map properties = describeProcedure(name, procedure.inputTypes); + String source = buildProcedureSource(name, procedure, properties); + return new ObjectSource(name, objectType, schema, source); + }); + } + + private void useSchema(String schema) throws SQLException { + if (schema == null || schema.trim().isEmpty()) { + return; + } + try (java.sql.Statement stmt = requireConnected().createStatement()) { + stmt.execute(DATABEND_PROFILE.schemaSwitchSql(schema.trim())); + } + } + + private List listProcedures(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + useSchema(schema); + List result = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT name, comment FROM system.procedures"); + List args = new ArrayList<>(); + if (constraints.hasFilter()) { + sql.append(" WHERE UPPER(name) LIKE ? ESCAPE '\\\\'"); + args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + sql.append(" ORDER BY name"); + if (constraints.hasLimit()) { + sql.append(" LIMIT ?"); + args.add(constraints.getLimit()); + if (constraints.hasOffset()) { + sql.append(" OFFSET ?"); + args.add(constraints.getOffset()); + } + } + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString("name"), "PROCEDURE", schema, rs.getString("comment"))); + } + } + } + MetadataListConstraints guard = constraints.hasLimit() ? constraints.withoutPaging() : constraints; + return guard.filterObjects(result); + }); + } + + private static boolean includesProcedures(MetadataListConstraints constraints) { + return !constraints.hasObjectTypes() || constraints.objectTypeAllowed("PROCEDURE"); + } + + private static void bind(PreparedStatement stmt, List args) throws SQLException { + for (int index = 0; index < args.size(); index += 1) { + Object arg = args.get(index); + if (arg instanceof Integer) { + stmt.setInt(index + 1, (Integer) arg); + } else { + stmt.setString(index + 1, String.valueOf(arg)); + } + } + } + + private ProcedureMetadata findProcedure(String name) throws SQLException { + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + "SELECT arguments, comment FROM system.procedures WHERE name = ? ORDER BY procedure_id LIMIT 1" + )) { + stmt.setString(1, name); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return null; + } + return new ProcedureMetadata( + rs.getString("comment"), + inputTypesFromArguments(rs.getString("arguments")) + ); + } + } + } + + private Map describeProcedure(String name, List inputTypes) throws SQLException { + Map result = new LinkedHashMap<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("DESC PROCEDURE " + simpleProcedureName(name) + "(" + String.join(", ", inputTypes) + ")")) { + while (rs.next()) { + result.put(rs.getString("Property").toLowerCase(Locale.ROOT), rs.getString("Value")); + } + } + return result; + } + + private static String buildProcedureSource(String name, ProcedureMetadata procedure, Map properties) { + List names = signatureNames(properties.get("signature")); + List arguments = new ArrayList<>(); + for (int index = 0; index < procedure.inputTypes.size(); index += 1) { + String argumentName = index < names.size() && !names.get(index).isEmpty() ? names.get(index) : "arg" + (index + 1); + arguments.add(argumentName + " " + procedure.inputTypes.get(index)); + } + + String returns = trimOuterParens(properties.getOrDefault("returns", "")); + String language = properties.getOrDefault("language", "SQL"); + String body = properties.getOrDefault("body", "").trim(); + StringBuilder source = new StringBuilder(); + source.append("CREATE PROCEDURE ").append(simpleProcedureName(name)).append("(").append(String.join(", ", arguments)).append(")"); + if (!returns.isEmpty()) { + source.append("\nRETURNS ").append(returns); + } + source.append("\nLANGUAGE ").append(language); + if (procedure.comment != null && !procedure.comment.isEmpty()) { + source.append("\nCOMMENT = '").append(procedure.comment.replace("'", "''")).append("'"); + } + source.append("\nAS $$\n").append(body).append("\n$$;"); + return source.toString(); + } + + private static String simpleProcedureName(String name) { + if (name != null && name.matches("[A-Za-z_][A-Za-z0-9_]*")) { + return name; + } + throw new IllegalArgumentException("Databend procedure names with special characters are not supported: " + name); + } + + private static List inputTypesFromArguments(String arguments) { + if (arguments == null) { + return Collections.emptyList(); + } + int open = arguments.indexOf('('); + if (open < 0) { + return Collections.emptyList(); + } + int close = matchingParen(arguments, open); + if (close < 0) { + return Collections.emptyList(); + } + return splitTopLevel(arguments.substring(open + 1, close)); + } + + private static List signatureNames(String signature) { + if (signature == null) { + return Collections.emptyList(); + } + String trimmed = trimOuterParens(signature); + return splitTopLevel(trimmed); + } + + private static List splitTopLevel(String value) { + if (value == null || value.trim().isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + int depth = 0; + for (int index = 0; index < value.length(); index += 1) { + char ch = value.charAt(index); + if (ch == '(') { + depth += 1; + } else if (ch == ')') { + depth = Math.max(0, depth - 1); + } else if (ch == ',' && depth == 0) { + addTrimmed(result, current.toString()); + current.setLength(0); + continue; + } + current.append(ch); + } + addTrimmed(result, current.toString()); + return result; + } + + private static void addTrimmed(List result, String value) { + String trimmed = value.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + + private static int matchingParen(String value, int open) { + int depth = 0; + for (int index = open; index < value.length(); index += 1) { + char ch = value.charAt(index); + if (ch == '(') { + depth += 1; + } else if (ch == ')') { + depth -= 1; + if (depth == 0) { + return index; + } + } + } + return -1; + } + + private static String trimOuterParens(String value) { + String trimmed = value == null ? "" : value.trim(); + if (trimmed.startsWith("(") && trimmed.endsWith(")") && matchingParen(trimmed, 0) == trimmed.length() - 1) { + return trimmed.substring(1, trimmed.length() - 1).trim(); + } + return trimmed; + } + + private static final class ProcedureMetadata { + private final String comment; + private final List inputTypes; + + ProcedureMetadata(String comment, List inputTypes) { + this.comment = comment; + this.inputTypes = inputTypes; + } + } + + public static void main(String[] args) { + new JsonRpcServer(new DatabendAgent()).run(); + } +} diff --git a/agents/drivers/databend/src/test/java/com/dbx/agent/databend/DatabendAgentTest.java b/agents/drivers/databend/src/test/java/com/dbx/agent/databend/DatabendAgentTest.java new file mode 100644 index 0000000..d875129 --- /dev/null +++ b/agents/drivers/databend/src/test/java/com/dbx/agent/databend/DatabendAgentTest.java @@ -0,0 +1,196 @@ +package com.dbx.agent.databend; + +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +class DatabendAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new DatabendAgent(); + } + + @Override + protected String resultSetSql() { + return "SELECT 1"; + } + + @Test + void buildsDefaultJdbcUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(0); + params.setDatabase("default"); + + Assertions.assertEquals( + "jdbc:databend://db.example.com:8000/default", + DatabendAgent.DATABEND_PROFILE.buildUrl(params) + ); + } + + @Test + void appendsQueryParametersToJdbcUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(20080); + params.setDatabase("default"); + params.setUrl_params("sslmode=disable"); + + Assertions.assertEquals( + "jdbc:databend://db.example.com:20080/default?sslmode=disable", + DatabendAgent.DATABEND_PROFILE.buildUrl(params) + ); + } + + @Test + void usesDoubleQuotedDatabaseAsExecutionContext() { + Assertions.assertEquals( + "USE \"analytics\"\"prod\"", + DatabendAgent.DATABEND_PROFILE.schemaSwitchSql("analytics\"prod") + ); + } + + @Test + void constrainedListObjectsKeepsDatabendProcedures() { + List sql = new ArrayList<>(); + DatabendAgent agent = new DatabendAgent(); + TestSupport.setPrivateConnection(agent, databendMetadataConnection(sql, resultSet( + new String[]{"name", "comment"}, + new Object[][]{ + {"refresh_orders", "proc comment"}, + {"format_order", null} + } + ))); + + List objects = agent.listObjects( + "default", + new MetadataListConstraints("format", 1, null, List.of("PROCEDURE")) + ); + + Assertions.assertEquals(1, objects.size()); + Assertions.assertEquals("format_order", objects.get(0).getName()); + Assertions.assertEquals("PROCEDURE", objects.get(0).getObject_type()); + Assertions.assertTrue(sql.stream().anyMatch(value -> value.contains("FROM system.procedures")), sql.toString()); + Assertions.assertTrue(sql.stream().anyMatch(value -> value.contains("UPPER(name) LIKE ?")), sql.toString()); + Assertions.assertTrue(sql.stream().anyMatch(value -> value.contains("LIMIT ?")), sql.toString()); + } + + private static Connection databendMetadataConnection(List sql, ResultSet procedures) { + DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> { + if ("getTableTypes".equals(method.getName()) || "getTables".equals(method.getName())) { + return emptyResultSet(); + } + return defaultValue(method.getReturnType()); + }); + Statement statement = proxy(Statement.class, (method, args) -> { + if ("execute".equals(method.getName()) || "close".equals(method.getName())) { + return "execute".equals(method.getName()) ? false : null; + } + if ("executeQuery".equals(method.getName())) { + return procedures; + } + return defaultValue(method.getReturnType()); + }); + PreparedStatement preparedStatement = proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + return procedures; + } + if ("setString".equals(method.getName()) || "setInt".equals(method.getName()) || "close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("getMetaData".equals(method.getName())) { + return metadata; + } + if ("createStatement".equals(method.getName())) { + return statement; + } + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return preparedStatement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet emptyResultSet() { + return resultSet(new String[]{"VALUE"}, new Object[][]{}); + } + + private static ResultSet resultSet(String[] columns, Object[][] rows) { + int[] index = {-1}; + return proxy(ResultSet.class, (method, args) -> { + switch (method.getName()) { + case "next": + index[0] += 1; + return index[0] < rows.length; + case "getString": + Object value = columnValue(columns, rows[index[0]], args[0]); + return value == null ? null : String.valueOf(value); + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static Object columnValue(String[] columns, Object[] row, Object key) { + if (key instanceof Number) { + return row[((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return row[i]; + } + } + return null; + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) return false; + if (type == Byte.TYPE) return (byte) 0; + if (type == Short.TYPE) return (short) 0; + if (type == Integer.TYPE) return 0; + if (type == Long.TYPE) return 0L; + if (type == Float.TYPE) return 0f; + if (type == Double.TYPE) return 0d; + if (type == Character.TYPE) return (char) 0; + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/databricks/build.gradle b/agents/drivers/databricks/build.gradle new file mode 100644 index 0000000..f6675d8 --- /dev/null +++ b/agents/drivers/databricks/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.databricks:databricks-jdbc:3.3.3' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Databricks SQL', 'Main-Class': 'com.dbx.agent.databricks.DatabricksAgent') + } +} diff --git a/agents/drivers/databricks/src/main/java/com/dbx/agent/databricks/DatabricksAgent.java b/agents/drivers/databricks/src/main/java/com/dbx/agent/databricks/DatabricksAgent.java new file mode 100644 index 0000000..5b5ef6b --- /dev/null +++ b/agents/drivers/databricks/src/main/java/com/dbx/agent/databricks/DatabricksAgent.java @@ -0,0 +1,41 @@ +package com.dbx.agent.databricks; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class DatabricksAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile DATABRICKS_PROFILE = new DatabricksProfile(); + + public DatabricksAgent() { + super(DATABRICKS_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new DatabricksAgent()).run(); + } + + private static final class DatabricksProfile extends JdbcAgentProfile { + private DatabricksProfile() { + super("com.databricks.client.jdbc.Driver", "jdbc:databricks://{host}:{port}/{database}", 443, true); + } + + @Override + public String buildUrl(ConnectParams params) { + if (!params.getConnection_string().trim().isEmpty()) { + return params.getConnection_string(); + } + int port = params.getPort() > 0 ? params.getPort() : getDefaultPort(); + String base = getUrlTemplate() + .replace("{host}", params.getHost()) + .replace("{port}", Integer.toString(port)) + .replace("{database}", params.getDatabase()); + String urlParams = params.getUrl_params() == null ? "" : params.getUrl_params().trim(); + while (urlParams.startsWith(";") || urlParams.startsWith("?") || urlParams.startsWith("&")) { + urlParams = urlParams.substring(1); + } + return urlParams.isEmpty() ? base : base + ";" + urlParams; + } + } +} diff --git a/agents/drivers/databricks/src/test/java/com/dbx/agent/databricks/DatabricksAgentTest.java b/agents/drivers/databricks/src/test/java/com/dbx/agent/databricks/DatabricksAgentTest.java new file mode 100644 index 0000000..80f5bcb --- /dev/null +++ b/agents/drivers/databricks/src/test/java/com/dbx/agent/databricks/DatabricksAgentTest.java @@ -0,0 +1,22 @@ +package com.dbx.agent.databricks; + +import com.dbx.agent.ConnectParams; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DatabricksAgentTest { + @Test + void buildsHttpPathUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("adb-123.azuredatabricks.net"); + params.setPort(443); + params.setDatabase("default"); + params.setUrl_params("httpPath=/sql/1.0/warehouses/abc;AuthMech=3"); + + assertEquals( + "jdbc:databricks://adb-123.azuredatabricks.net:443/default;httpPath=/sql/1.0/warehouses/abc;AuthMech=3", + DatabricksAgent.DATABRICKS_PROFILE.buildUrl(params) + ); + } +} diff --git a/agents/drivers/db2/build.gradle b/agents/drivers/db2/build.gradle new file mode 100644 index 0000000..d9af1aa --- /dev/null +++ b/agents/drivers/db2/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.ibm.db2:jcc:11.5.9.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'IBM DB2', 'Main-Class': 'com.dbx.agent.db2.Db2Agent') + } +} diff --git a/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java b/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java new file mode 100644 index 0000000..047b11c --- /dev/null +++ b/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java @@ -0,0 +1,515 @@ +package com.dbx.agent.db2; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class Db2Agent extends BaseDatabaseAgent { + private static final Set NUMERIC_PRECISION_TYPES = Set.of( + "DECIMAL", "NUMERIC", "INTEGER", "SMALLINT", "BIGINT", "REAL", "DOUBLE", "FLOAT" + ); + private static final Set NUMERIC_SCALE_TYPES = Set.of("DECIMAL", "NUMERIC"); + private static final Set CHARACTER_LENGTH_TYPES = Set.of("VARCHAR", "CHAR", "CLOB", "GRAPHIC", "VARGRAPHIC"); + + private Connection connection; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public String setSchemaSQL(String schema) { + return "SET SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + Class.forName("com.ibm.db2.jcc.DB2Driver"); + connection = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword()); + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + Class.forName("com.ibm.db2.jcc.DB2Driver"); + try (Connection conn = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT CURRENT_SERVER FROM SYSIBM.SYSDUMMY1"; + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1).trim())); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT SCHEMANAME FROM SYSCAT.SCHEMATA ORDER BY SCHEMANAME"; + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(rs.getString(1).trim()); + } + } + return result; + }); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT TABNAME, TYPE FROM SYSCAT.TABLES WHERE TABSCHEMA = ? AND TYPE IN ('T','V') ORDER BY TABNAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String db2Type = rs.getString(2).trim(); + String type = switch (db2Type) { + case "T" -> "TABLE"; + case "V" -> "VIEW"; + default -> db2Type; + }; + result.add(new TableInfo(rs.getString(1).trim(), type, null)); + } + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TABNAME, TYPE FROM SYSCAT.TABLES WHERE TABSCHEMA = ?"); + args.add(schema); + appendDb2TableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "TABNAME", constraints); + sql.append(" ORDER BY TABNAME"); + MetadataSqlSupport.appendLiteralOffsetFetch(sql, constraints); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1).trim(), db2TableType(rs.getString(2)), null)); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + + String sql = "SELECT PROCNAME, 'PROCEDURE' FROM SYSCAT.PROCEDURES WHERE PROCSCHEMA = ? ORDER BY PROCNAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1).trim(), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return queryConstrainedObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + StringBuilder tableSql = new StringBuilder( + "SELECT TABNAME AS OBJECT_NAME, CASE TYPE WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE TYPE END AS OBJECT_TYPE FROM SYSCAT.TABLES WHERE TABSCHEMA = ?" + ); + args.add(schema); + appendDb2TableTypePredicate(tableSql, args, constraints); + MetadataSqlSupport.appendNameFilter(tableSql, args, "TABNAME", constraints); + branches.add(tableSql.toString()); + } + if (constraints.objectTypeAllowed("PROCEDURE")) { + StringBuilder procedureSql = new StringBuilder( + "SELECT PROCNAME AS OBJECT_NAME, 'PROCEDURE' AS OBJECT_TYPE FROM SYSCAT.PROCEDURES WHERE PROCSCHEMA = ?" + ); + args.add(schema); + MetadataSqlSupport.appendNameFilter(procedureSql, args, "PROCNAME", constraints); + branches.add(procedureSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 ELSE 9 END, OBJECT_NAME"); + MetadataSqlSupport.appendLiteralOffsetFetch(sql, constraints); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1).trim(), rs.getString(2), schema, null)); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String sql = "SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?"; + String source; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + source = rs.next() ? coalesce(rs.getString(1)) : ""; + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Set pkColumns = new java.util.HashSet<>(); + String pkSql = """ + SELECT kc.COLNAME FROM SYSCAT.KEYCOLUSE kc + JOIN SYSCAT.TABCONST tc ON kc.CONSTNAME = tc.CONSTNAME AND kc.TABSCHEMA = tc.TABSCHEMA AND kc.TABNAME = tc.TABNAME + WHERE tc.TYPE = 'P' AND tc.TABSCHEMA = ? AND tc.TABNAME = ? + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(pkSql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + pkColumns.add(rs.getString(1).trim()); + } + } + } + + List result = new ArrayList<>(); + String colSql = """ + SELECT COLNAME, TYPENAME, NULLS, DEFAULT, LENGTH, SCALE + FROM SYSCAT.COLUMNS + WHERE TABSCHEMA = ? AND TABNAME = ? + ORDER BY COLNO + """.stripIndent().trim(); + try (PreparedStatement stmt = requireConnected().prepareStatement(colSql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = rs.getString("COLNAME").trim(); + String typeName = rs.getString("TYPENAME").trim(); + Integer length = intObject(rs, "LENGTH"); + Integer scale = intObject(rs, "SCALE"); + String dataType = formatDataType(typeName, length, scale); + + result.add(new ColumnInfo( + name, + dataType, + "Y".equals(rs.getString("NULLS").trim()), + trimNullable(rs.getString("DEFAULT")), + pkColumns.contains(name), + null, + null, + NUMERIC_PRECISION_TYPES.contains(typeName) ? length : null, + NUMERIC_SCALE_TYPES.contains(typeName) ? scale : null, + CHARACTER_LENGTH_TYPES.contains(typeName) ? length : null + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT INDNAME, COLNAMES, UNIQUERULE FROM SYSCAT.INDEXES WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY INDNAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String colNames = trimToEmpty(rs.getString(2)); + List columns = splitColumns(colNames, "[+-]"); + String uniqueRule = trimToEmpty(rs.getString(3)); + result.add(new IndexInfo( + rs.getString(1).trim(), + columns, + "U".equals(uniqueRule) || "P".equals(uniqueRule), + "P".equals(uniqueRule), + null, + null, + null, + null + )); + } + } + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT CONSTNAME, FK_COLNAMES, REFTABNAME, PK_COLNAMES FROM SYSCAT.REFERENCES WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY CONSTNAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + List fkColList = splitColumns(trimToEmpty(rs.getString(2)), "\\s+"); + List pkColList = splitColumns(trimToEmpty(rs.getString(4)), "\\s+"); + String refTable = rs.getString(3).trim(); + String constName = rs.getString(1).trim(); + for (int i = 0; i < fkColList.size(); i++) { + result.add(new ForeignKeyInfo( + constName, + fkColList.get(i), + refTable, + i < pkColList.size() ? pkColList.get(i) : "" + )); + } + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT TRIGNAME, TRIGEVENT, TRIGTIME FROM SYSCAT.TRIGGERS WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY TRIGNAME"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo( + rs.getString(1).trim(), + rs.getString(2).trim(), + rs.getString(3).trim() + )); + } + } + } + return result; + }); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::stringResultValue + ); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private Object stringResultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + static String buildUrl(ConnectParams params) { + if (!params.getConnection_string().trim().isEmpty()) { + return params.getConnection_string(); + } + String url = "jdbc:db2://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase(); + String extraParams = trimDb2UrlParams(params.getUrl_params()); + if (extraParams.isEmpty()) { + return url; + } + return url + ":" + extraParams + (extraParams.endsWith(";") ? "" : ";"); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() || constraints.objectTypeAllowed("PROCEDURE"); + } + + private static void appendDb2TableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("T"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("V"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static String db2TableType(String value) { + String db2Type = value == null ? "" : value.trim(); + return switch (db2Type) { + case "T" -> "TABLE"; + case "V" -> "VIEW"; + default -> db2Type; + }; + } + + private static String trimDb2UrlParams(String urlParams) { + String value = urlParams == null ? "" : urlParams.trim(); + while (value.startsWith("?") || value.startsWith("&") || value.startsWith(":") || value.startsWith(";")) { + value = value.substring(1); + } + return value; + } + + private static String formatDataType(String typeName, Integer length, Integer scale) { + return switch (typeName.toUpperCase(Locale.ROOT)) { + case "VARCHAR", "CHAR", "CLOB", "GRAPHIC", "VARGRAPHIC" -> length != null ? typeName + "(" + length + ")" : typeName; + case "DECIMAL", "NUMERIC" -> { + if (length != null && scale != null && scale > 0) { + yield typeName + "(" + length + "," + scale + ")"; + } + yield length != null ? typeName + "(" + length + ")" : typeName; + } + default -> typeName; + }; + } + + private static Integer intObject(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value == null ? null : ((Number) value).intValue(); + } + + private static List splitColumns(String value, String regex) { + List result = new ArrayList<>(); + for (String part : value.split(regex)) { + if (!part.isBlank()) { + result.add(part); + } + } + return result; + } + + private static String trimNullable(String value) { + return value == null ? null : value.trim(); + } + + private static String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + public static void main(String[] args) { + new JsonRpcServer(new Db2Agent()).run(); + } +} diff --git a/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java b/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java new file mode 100644 index 0000000..1eeb38d --- /dev/null +++ b/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java @@ -0,0 +1,195 @@ +package com.dbx.agent.db2; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Db2AgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new Db2Agent(); + } + + @Override + protected String resultSetSql() { + return "CALL ADMIN_CMD('list applications')"; + } + + @Test + void buildsJdbcUrlWithDb2PropertySuffix() { + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(50000); + params.setDatabase("SAMPLE"); + params.setUrl_params("sslConnection=true"); + + assertEquals("jdbc:db2://db.example.com:50000/SAMPLE:sslConnection=true;", Db2Agent.buildUrl(params)); + } + + @Test + void listsAllCatalogSchemasWithoutOwnerTypeFiltering() { + Db2Agent agent = new Db2Agent(); + AtomicReference executedSql = new AtomicReference<>(); + TestSupport.setPrivateConnection(agent, connection( + executedSql, + rows(row("SCHEMANAME", "APP"), row("SCHEMANAME", "SZ"), row("SCHEMANAME", "TOOLS")) + )); + + List schemas = agent.listSchemas(); + + assertEquals(Arrays.asList("APP", "SZ", "TOOLS"), schemas); + assertEquals("SELECT SCHEMANAME FROM SYSCAT.SCHEMATA ORDER BY SCHEMANAME", executedSql.get()); + assertFalse(executedSql.get().contains("OWNERTYPE")); + } + + @Test + void constrainedTableMetadataUsesDb2CatalogPushdown() { + Db2Agent agent = new Db2Agent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("APP", new MetadataListConstraints("ord", 25, 50, List.of("TABLE"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + assertTrue(sql.contains("FROM SYSCAT.TABLES")); + assertTrue(sql.contains("TYPE IN (?)")); + assertTrue(sql.contains("UPPER(TABNAME) LIKE ? ESCAPE '\\\\'")); + assertTrue(sql.endsWith("OFFSET 50 ROWS FETCH NEXT 25 ROWS ONLY")); + assertEquals(Arrays.asList("param:1=APP", "param:2=T", "param:3=%O%R%D%"), JdbcMetadataSqlFake.statements.subList(1, 4)); + } + + @Test + void constrainedObjectMetadataUsesDb2CatalogPushdown() { + Db2Agent agent = new Db2Agent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listObjects("APP", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + assertTrue(sql.contains("FROM SYSCAT.PROCEDURES")); + assertTrue(sql.contains("ORDER BY CASE OBJECT_TYPE")); + assertTrue(sql.endsWith("FETCH FIRST 10 ROWS ONLY")); + assertEquals(Arrays.asList("param:1=APP", "param:2=%S%Y%N%C%"), JdbcMetadataSqlFake.statements.subList(1, 3)); + } + + private static Connection connection(AtomicReference executedSql, ResultSet resultSet) { + Statement statement = proxy(Statement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("executeQuery".equals(method.getName())) { + executedSql.set(String.valueOf(args[0])); + return resultSet; + } + if ("close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("createStatement".equals(method.getName())) { + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet rows(Map... rows) { + return proxy(ResultSet.class, new MethodHandler() { + private int index = -1; + + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + index += 1; + return index < rows.length; + } + if ("getString".equals(name)) { + Object key = args[0] instanceof Number ? rows[index].keySet().iterator().next() : args[0]; + Object value = rows[index].get(key); + return value == null ? null : String.valueOf(value); + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + row.put(String.valueOf(values[i]), values[i + 1]); + } + return row; + } + + private static T proxy(Class type, final MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/drivers/etcd/build.gradle b/agents/drivers/etcd/build.gradle new file mode 100644 index 0000000..d37a950 --- /dev/null +++ b/agents/drivers/etcd/build.gradle @@ -0,0 +1,11 @@ +dependencies { + implementation 'com.google.code.gson:gson:2.12.1' + implementation 'io.etcd:jetcd-core:0.8.6' +} + +tasks.named('shadowJar') { + mergeServiceFiles() + manifest { + attributes('Agent-Label': 'etcd', 'Main-Class': 'com.dbx.agent.etcd.EtcdAgent') + } +} diff --git a/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java b/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java new file mode 100644 index 0000000..c29286f --- /dev/null +++ b/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java @@ -0,0 +1,420 @@ +package com.dbx.agent.etcd; + +import com.dbx.agent.AgentProtocol; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.grpc.netty.GrpcSslContexts; +import io.etcd.jetcd.ByteSequence; +import io.etcd.jetcd.Client; +import io.etcd.jetcd.ClientBuilder; +import io.etcd.jetcd.KV; +import io.etcd.jetcd.KeyValue; +import io.etcd.jetcd.kv.DeleteResponse; +import io.etcd.jetcd.kv.GetResponse; +import io.etcd.jetcd.kv.PutResponse; +import io.etcd.jetcd.options.GetOption; +import io.etcd.jetcd.options.PutOption; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public final class EtcdAgent { + private static final Gson GSON = new Gson(); + private static final int DEFAULT_LIMIT = 100; + private static final int RPC_TIMEOUT_SECONDS = 30; + private static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( + AgentProtocol.CAPABILITY_CONNECT, + AgentProtocol.CAPABILITY_TEST_CONNECTION, + AgentProtocol.CAPABILITY_KV + )); + private static Client client; + private static KV kv; + + private EtcdAgent() { + } + + private static Object handshakeResult() { + return new HandshakeResult(AgentProtocol.PROTOCOL_VERSION, AgentProtocol.PROTOCOL_VERSION, CAPABILITIES); + } + + private static Object connect(JsonObject params) throws Exception { + JsonObject connection = connectionObject(params); + Client nextClient = buildClient(connection); + nextClient.getKVClient().get(byteSequence("\0")).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + closeClient(); + client = nextClient; + kv = client.getKVClient(); + return Collections.singletonMap("ok", true); + } + + static Client buildClient(JsonObject connection) throws Exception { + List endpoints = endpoints(connection); + ClientBuilder builder = Client.builder().endpoints(endpoints.toArray(String[]::new)); + String username = stringOrEmpty(connection, "username"); + String password = stringOrEmpty(connection, "password"); + if (!username.isBlank()) { + builder.user(byteSequence(username)); + builder.password(byteSequence(password)); + } + if (boolOrDefault(connection, "ssl", false)) { + builder.sslContext(sslContext(connection)); + } + return builder.build(); + } + + static List endpoints(JsonObject connection) { + String configured = firstNonBlank( + stringOrNull(connection, "etcd_endpoints"), + stringOrNull(connection, "endpoints"), + stringOrNull(connection, "connection_string") + ); + List result = new ArrayList<>(); + if (configured != null) { + for (String endpoint : configured.split("[,\\n]")) { + String normalized = normalizeEndpoint(endpoint.trim(), boolOrDefault(connection, "ssl", false)); + if (!normalized.isBlank()) { + result.add(normalized); + } + } + } + if (result.isEmpty()) { + String host = stringOrDefault(connection, "host", "127.0.0.1"); + int port = intOrDefault(connection, "port", 2379); + result.add(normalizeEndpoint(host + ":" + port, boolOrDefault(connection, "ssl", false))); + } + return result; + } + + private static String normalizeEndpoint(String endpoint, boolean tls) { + if (endpoint.isBlank()) { + return ""; + } + if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + return (tls ? "https://" : "http://") + endpoint; + } + + private static SslContext sslContext(JsonObject connection) throws Exception { + SslContextBuilder builder = GrpcSslContexts.forClient(); + String ca = stringOrEmpty(connection, "ca_cert_path"); + if (!ca.isBlank()) { + builder.trustManager(new File(ca)); + } + String cert = firstNonBlank(stringOrNull(connection, "client_cert_path"), stringOrNull(connection, "cert_path")); + String key = firstNonBlank(stringOrNull(connection, "client_key_path"), stringOrNull(connection, "key_path")); + if ((cert == null) != (key == null)) { + throw new IllegalArgumentException("Client certificate and key must be provided together"); + } + if (cert != null) { + builder.keyManager(new File(cert), new File(key)); + } + return builder.build(); + } + + private static Object listPrefix(JsonObject params) throws Exception { + KV active = requireKv(); + String prefix = stringOrDefault(params, "prefix", ""); + int limit = intOrDefault(params, "limit", DEFAULT_LIMIT); + String continuation = stringOrNull(params, "continuation"); + ByteSequence start = continuation == null || continuation.isBlank() + ? prefixStart(prefix) + : ByteSequence.from(Base64.getDecoder().decode(continuation)); + GetOption option = GetOption.newBuilder() + .withRange(prefixEnd(byteSequence(prefix))) + .withLimit(Math.max(1, limit)) + .withSortField(GetOption.SortTarget.KEY) + .withSortOrder(GetOption.SortOrder.ASCEND) + .build(); + GetResponse response = active.get(start, option).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + List> keys = new ArrayList<>(); + List kvs = response.getKvs(); + for (KeyValue item : kvs) { + Map row = metadata(item); + row.put("key", displayBytes(item.getKey().getBytes())); + keys.add(row); + } + + Map result = new LinkedHashMap<>(); + result.put("keys", keys); + result.put("continuation", response.isMore() && !kvs.isEmpty() ? nextContinuation(kvs.get(kvs.size() - 1)) : null); + result.put("revision", response.getHeader().getRevision()); + return result; + } + + private static Object get(JsonObject params) throws Exception { + KV active = requireKv(); + String key = params.get("key").getAsString(); + GetResponse response = active.get(byteSequence(key)).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Map result = new LinkedHashMap<>(); + if (response.getKvs().isEmpty()) { + result.put("found", false); + result.put("key", null); + result.put("value", null); + result.put("metadata", null); + return result; + } + KeyValue item = response.getKvs().get(0); + result.put("found", true); + result.put("key", displayBytes(item.getKey().getBytes())); + result.put("value", valueObject(item.getValue().getBytes())); + result.put("metadata", metadata(item)); + return result; + } + + private static Object put(JsonObject params) throws Exception { + KV active = requireKv(); + String key = params.get("key").getAsString(); + byte[] value = parseValue(params.getAsJsonObject("value")); + JsonElement leaseElement = params.get("lease"); + PutResponse response; + if (leaseElement != null && !leaseElement.isJsonNull()) { + PutOption option = PutOption.newBuilder().withLeaseId(leaseElement.getAsLong()).build(); + response = active.put(byteSequence(key), ByteSequence.from(value), option).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } else { + response = active.put(byteSequence(key), ByteSequence.from(value)).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + return Collections.singletonMap("revision", response.getHeader().getRevision()); + } + + private static Object delete(JsonObject params) throws Exception { + KV active = requireKv(); + DeleteResponse response = + active.delete(byteSequence(params.get("key").getAsString())).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Map result = new LinkedHashMap<>(); + result.put("deleted", response.getDeleted()); + result.put("revision", response.getHeader().getRevision()); + return result; + } + + private static Object dispatch(String method, JsonObject params) throws Exception { + return switch (method) { + case AgentProtocol.METHOD_HANDSHAKE -> handshakeResult(); + case AgentProtocol.METHOD_CONNECT, AgentProtocol.METHOD_TEST_CONNECTION -> connect(params); + case AgentProtocol.KV_METHOD_LIST_PREFIX -> listPrefix(params); + case AgentProtocol.KV_METHOD_GET -> get(params); + case AgentProtocol.KV_METHOD_PUT -> put(params); + case AgentProtocol.KV_METHOD_DELETE -> delete(params); + case AgentProtocol.METHOD_DISCONNECT -> { + closeClient(); + yield Collections.singletonMap("ok", true); + } + case AgentProtocol.METHOD_SHUTDOWN -> { + closeClient(); + System.exit(0); + yield Collections.singletonMap("ok", true); + } + default -> throw new IllegalArgumentException("Unknown method: " + method); + }; + } + + static String handleRequest(String line) { + JsonObject req = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = req.get("id"); + String method = req.get("method").getAsString(); + JsonObject params = req.has("params") && req.get("params").isJsonObject() + ? req.getAsJsonObject("params") + : new JsonObject(); + + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + response.add("id", id); + + try { + Object result = dispatch(method, params); + response.add("result", GSON.toJsonTree(result)); + } catch (Exception e) { + JsonObject error = new JsonObject(); + error.addProperty("code", -1); + error.addProperty("message", e.getMessage() == null ? "Unknown error" : e.getMessage()); + response.add("error", error); + } + + return GSON.toJson(response); + } + + private static JsonObject connectionObject(JsonObject params) { + JsonElement connection = params.get("connection"); + return connection != null && connection.isJsonObject() ? connection.getAsJsonObject() : params; + } + + private static KV requireKv() { + if (kv == null) { + throw new IllegalStateException("Not connected"); + } + return kv; + } + + private static void closeClient() { + if (kv != null) { + kv.close(); + kv = null; + } + if (client != null) { + client.close(); + client = null; + } + } + + private static ByteSequence byteSequence(String value) { + return ByteSequence.from(value, java.nio.charset.StandardCharsets.UTF_8); + } + + private static ByteSequence prefixEnd(ByteSequence prefix) { + byte[] bytes = prefix.getBytes(); + if (bytes.length == 0) { + return ByteSequence.from(new byte[] {0}); + } + byte[] end = Arrays.copyOf(bytes, bytes.length); + for (int i = end.length - 1; i >= 0; i--) { + if ((end[i] & 0xff) < 0xff) { + end[i]++; + return ByteSequence.from(Arrays.copyOf(end, i + 1)); + } + } + return ByteSequence.from(new byte[] {0}); + } + + private static ByteSequence prefixStart(String prefix) { + if (prefix.isEmpty()) { + return ByteSequence.from(new byte[] {0}); + } + return byteSequence(prefix); + } + + private static String nextContinuation(KeyValue item) { + byte[] key = item.getKey().getBytes(); + byte[] next = Arrays.copyOf(key, key.length + 1); + next[next.length - 1] = 0; + return Base64.getEncoder().encodeToString(next); + } + + private static Map metadata(KeyValue item) { + Map metadata = new LinkedHashMap<>(); + metadata.put("createRevision", item.getCreateRevision()); + metadata.put("modRevision", item.getModRevision()); + metadata.put("version", item.getVersion()); + metadata.put("lease", item.getLease()); + metadata.put("valueSize", item.getValue().size()); + return metadata; + } + + private static Map valueObject(byte[] bytes) { + Map value = new LinkedHashMap<>(); + String utf8 = strictUtf8(bytes); + if (utf8 != null) { + value.put("encoding", "utf8"); + value.put("data", utf8); + } else { + value.put("encoding", "base64"); + value.put("data", Base64.getEncoder().encodeToString(bytes)); + } + return value; + } + + private static byte[] parseValue(JsonObject value) { + String encoding = stringOrDefault(value, "encoding", "utf8"); + String data = stringOrDefault(value, "data", ""); + if ("base64".equals(encoding)) { + return Base64.getDecoder().decode(data); + } + if (!"utf8".equals(encoding)) { + throw new IllegalArgumentException("Unsupported value encoding: " + encoding); + } + return data.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static String displayBytes(byte[] bytes) { + String utf8 = strictUtf8(bytes); + return utf8 == null ? Base64.getEncoder().encodeToString(bytes) : utf8; + } + + private static String strictUtf8(byte[] bytes) { + CharsetDecoder decoder = java.nio.charset.StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return decoder.decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + return null; + } + } + + private static String stringOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } + + private static String stringOrEmpty(JsonObject object, String key) { + return stringOrDefault(object, key, ""); + } + + private static String stringOrDefault(JsonObject object, String key, String fallback) { + String value = stringOrNull(object, key); + return value == null ? fallback : value; + } + + private static int intOrDefault(JsonObject object, String key, int fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsInt(); + } + + private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsBoolean(); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + public static void main(String[] args) throws Exception { + System.out.println("{\"ready\":true}"); + System.out.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + while (true) { + String line = reader.readLine(); + if (line == null) { + break; + } + + System.out.println(handleRequest(line)); + System.out.flush(); + } + } + + private static final class HandshakeResult { + private final int protocolVersion; + private final int agentProtocolVersion; + private final List capabilities; + + private HandshakeResult(int protocolVersion, int agentProtocolVersion, List capabilities) { + this.protocolVersion = protocolVersion; + this.agentProtocolVersion = agentProtocolVersion; + this.capabilities = capabilities; + } + } +} diff --git a/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java b/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java new file mode 100644 index 0000000..65c205d --- /dev/null +++ b/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java @@ -0,0 +1,55 @@ +package com.dbx.agent.etcd; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +final class EtcdAgentTest { + @Test + void handshakeAdvertisesKvCapability() { + String response = EtcdAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}" + ); + + JsonObject result = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result"); + + Assertions.assertEquals(1, result.get("protocolVersion").getAsInt()); + Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv\""))); + Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"connect\""))); + } + + @Test + void endpointsUseConfiguredListAndScheme() { + JsonObject connection = JsonParser.parseString( + "{\"endpoints\":\"etcd-1:2379,https://etcd-2:2379\",\"ssl\":true}" + ).getAsJsonObject(); + + Assertions.assertEquals( + List.of("https://etcd-1:2379", "https://etcd-2:2379"), + EtcdAgent.endpoints(connection) + ); + } + + @Test + void endpointsFallbackToHostPort() { + JsonObject connection = JsonParser.parseString( + "{\"host\":\"127.0.0.1\",\"port\":2379}" + ).getAsJsonObject(); + + Assertions.assertEquals(List.of("http://127.0.0.1:2379"), EtcdAgent.endpoints(connection)); + } + + @Test + void kvMethodDispatchReturnsJsonRpcErrorWhenDisconnected() { + String response = EtcdAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"kv_get\",\"params\":{\"key\":\"/app/name\"}}" + ); + + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + + Assertions.assertEquals(-1, error.get("code").getAsInt()); + Assertions.assertEquals("Not connected", error.get("message").getAsString()); + } +} diff --git a/agents/drivers/exasol/build.gradle b/agents/drivers/exasol/build.gradle new file mode 100644 index 0000000..71dda62 --- /dev/null +++ b/agents/drivers/exasol/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.exasol:exasol-jdbc:26.2.7' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Exasol', 'Main-Class': 'com.dbx.agent.exasol.ExasolAgent') + } +} diff --git a/agents/drivers/exasol/src/main/java/com/dbx/agent/exasol/ExasolAgent.java b/agents/drivers/exasol/src/main/java/com/dbx/agent/exasol/ExasolAgent.java new file mode 100644 index 0000000..8dd7bdd --- /dev/null +++ b/agents/drivers/exasol/src/main/java/com/dbx/agent/exasol/ExasolAgent.java @@ -0,0 +1,22 @@ +package com.dbx.agent.exasol; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class ExasolAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile EXASOL_PROFILE = new JdbcAgentProfile( + "com.exasol.jdbc.EXADriver", + "jdbc:exa:{host}:{port};schema={database}", + 8563, + true + ); + + public ExasolAgent() { + super(EXASOL_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new ExasolAgent()).run(); + } +} diff --git a/agents/drivers/firebird/build.gradle b/agents/drivers/firebird/build.gradle new file mode 100644 index 0000000..701aad5 --- /dev/null +++ b/agents/drivers/firebird/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.firebirdsql.jdbc:jaybird:6.0.5' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Firebird', 'Main-Class': 'com.dbx.agent.firebird.FirebirdAgent') + } +} diff --git a/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java b/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java new file mode 100644 index 0000000..ed57d29 --- /dev/null +++ b/agents/drivers/firebird/src/main/java/com/dbx/agent/firebird/FirebirdAgent.java @@ -0,0 +1,22 @@ +package com.dbx.agent.firebird; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class FirebirdAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile FIREBIRD_PROFILE = new JdbcAgentProfile( + "org.firebirdsql.jdbc.FBDriver", + "jdbc:firebirdsql://{host}:{port}/{database}", + 3050, + true + ); + + public FirebirdAgent() { + super(FIREBIRD_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new FirebirdAgent()).run(); + } +} diff --git a/agents/drivers/gbase8a/build.gradle b/agents/drivers/gbase8a/build.gradle new file mode 100644 index 0000000..a066170 --- /dev/null +++ b/agents/drivers/gbase8a/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation files('libs/gbase-connector-java-9.5.0.10-build1-bin.jar') +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'GBase 8a', + 'Main-Class': 'com.dbx.agent.gbase8a.Gbase8aAgent' + ) + } +} diff --git a/agents/drivers/gbase8a/src/main/java/com/dbx/agent/gbase8a/Gbase8aAgent.java b/agents/drivers/gbase8a/src/main/java/com/dbx/agent/gbase8a/Gbase8aAgent.java new file mode 100644 index 0000000..92ba7cc --- /dev/null +++ b/agents/drivers/gbase8a/src/main/java/com/dbx/agent/gbase8a/Gbase8aAgent.java @@ -0,0 +1,345 @@ +package com.dbx.agent.gbase8a; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import com.dbx.agent.StandardJdbcMetadata; +import com.dbx.agent.TableInfo; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public final class Gbase8aAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile GBASE8A_PROFILE = new JdbcAgentProfile( + "com.gbase.jdbc.Driver", + "jdbc:gbase://{host}:{port}/{database}?useSSL=false", + 5258, + false, + java.util.Collections.emptySet(), + java.util.Arrays.asList("TABLE", "VIEW", "BASE TABLE"), + "`", + "USE", + true, + false, + true, + false + ); + + public Gbase8aAgent() { + super(GBASE8A_PROFILE); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return super.executeQuery(sql, schema, withoutFetchSize(options)); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + return super.executeQueryPage(sql, schema, withoutFetchSize(options)); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return super.startTableRead(sql, schema, withoutFetchSize(options)); + } + + @Override + public List listTables(String schema) { + return queryTables(schema, MetadataListConstraints.NONE); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + return queryTables(schema, MetadataListConstraints.orNone(constraints)); + } + + private List queryTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + if (!constraints.includesTableLikeTypes()) { + return List.of(); + } + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE "); + if (hasSchema(schema)) { + sql.append("TABLE_SCHEMA = ?"); + args.add(schema); + } else { + sql.append("TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'gclusterdb', 'gctmpdb')"); + } + appendGbase8aTableTypePredicate(sql, args, constraints); + appendNameFilter(sql, args, "TABLE_NAME", constraints); + sql.append(hasSchema(schema) ? " ORDER BY TABLE_NAME" : " ORDER BY TABLE_SCHEMA, TABLE_NAME"); + appendLimitOffset(sql, args, constraints); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String tableType = rs.getString("TABLE_TYPE"); + if ("BASE TABLE".equals(tableType)) { + tableType = "TABLE"; + } + result.add(new TableInfo(rs.getString("TABLE_NAME"), tableType, null)); + } + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + MetadataListConstraints guard = constraints.hasLimit() ? constraints.withoutPaging() : constraints; + return guard.filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return queryObjects(schema, MetadataListConstraints.NONE); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + return queryObjects(schema, MetadataListConstraints.orNone(constraints)); + } + + private List queryObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + boolean includeTables = constraints.includesTableLikeTypes(); + boolean includeRoutines = includesRoutineTypes(constraints); + if (!includeTables && !includeRoutines) { + return List.of(); + } + List result = new ArrayList<>(); + if (includeTables) { + MetadataListConstraints tableConstraints = includeRoutines + ? new MetadataListConstraints(constraints.getFilter(), null, null, tableLikeObjectTypes(constraints)) + : constraints; + result.addAll(StandardJdbcMetadata.INSTANCE.listObjects(queryTables(schema, tableConstraints), schema)); + } + if (includeRoutines) { + MetadataListConstraints routineConstraints = includeTables + ? new MetadataListConstraints(constraints.getFilter(), null, null, routineObjectTypes(constraints)) + : constraints; + appendRoutines(result, schema, routineConstraints); + } + boolean singlePagedSource = includeTables != includeRoutines && constraints.hasLimit(); + MetadataListConstraints guard = singlePagedSource ? constraints.withoutPaging() : constraints; + return guard.filterObjects(result); + }); + } + + private void appendRoutines(List result, String schema, MetadataListConstraints constraints) throws Exception { + StringBuilder sql = new StringBuilder(); + List args = new ArrayList<>(); + boolean hasSchema = hasSchema(schema); + if (hasSchema) { + sql.append("SELECT ROUTINE_NAME, ROUTINE_TYPE, ROUTINE_COMMENT FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?"); + args.add(schema); + } else { + sql.append("SELECT ROUTINE_SCHEMA, ROUTINE_NAME, ROUTINE_TYPE, ROUTINE_COMMENT FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'gclusterdb', 'gctmpdb')"); + } + appendGbase8aRoutineTypePredicate(sql, args, constraints); + appendNameFilter(sql, args, "ROUTINE_NAME", constraints); + sql.append(hasSchema ? " ORDER BY ROUTINE_NAME" : " ORDER BY ROUTINE_SCHEMA, ROUTINE_NAME"); + appendLimitOffset(sql, args, constraints); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String routineSchema = hasSchema ? schema : rs.getString("ROUTINE_SCHEMA"); + result.add(new ObjectInfo( + rs.getString("ROUTINE_NAME"), + normalizeRoutineType(rs.getString("ROUTINE_TYPE")), + routineSchema, + rs.getString("ROUTINE_COMMENT") + )); + } + } + } + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String normalizedType = objectType.toUpperCase(Locale.ROOT); + String sql = switch (normalizedType) { + case "VIEW" -> "SHOW CREATE VIEW "; + case "PROCEDURE" -> "SHOW CREATE PROCEDURE "; + case "FUNCTION" -> "SHOW CREATE FUNCTION "; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + String qualifiedName = hasSchema(schema) + ? JdbcIdentifiers.INSTANCE.backtick(schema) + "." + JdbcIdentifiers.INSTANCE.backtick(name) + : JdbcIdentifiers.INSTANCE.backtick(name); + + String source = ""; + try (Statement stmt = requireConnection().createStatement(); + ResultSet rs = stmt.executeQuery(sql + qualifiedName)) { + if (rs.next()) { + // SHOW CREATE result layouts vary by GBase driver version, so locate the definition column by label. + ResultSetMetaData metadata = rs.getMetaData(); + for (int index = 1; index <= metadata.getColumnCount(); index++) { + String label = metadata.getColumnLabel(index); + if (label != null && label.toUpperCase(Locale.ROOT).startsWith("CREATE ")) { + String value = rs.getString(index); + source = value == null ? "" : value; + break; + } + } + } + } + return new ObjectSource(name, normalizedType, schema, source); + }); + } + + private static void appendGbase8aTableTypePredicate( + StringBuilder sql, + List args, + MetadataListConstraints constraints + ) { + if (!constraints.hasObjectTypes()) { + return; + } + List tableTypes = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + tableTypes.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + tableTypes.add("VIEW"); + } + if (tableTypes.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND TABLE_TYPE IN (").append(placeholders(tableTypes.size())).append(")"); + args.addAll(tableTypes); + } + + private static void appendGbase8aRoutineTypePredicate( + StringBuilder sql, + List args, + MetadataListConstraints constraints + ) { + if (!constraints.hasObjectTypes()) { + return; + } + List routineTypes = new ArrayList<>(); + if (constraints.objectTypeAllowed("PROCEDURE")) { + routineTypes.add("PROCEDURE"); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + routineTypes.add("FUNCTION"); + } + if (routineTypes.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND ROUTINE_TYPE IN (").append(placeholders(routineTypes.size())).append(")"); + args.addAll(routineTypes); + } + + private static void appendNameFilter(StringBuilder sql, List args, String column, MetadataListConstraints constraints) { + if (!constraints.hasFilter()) { + return; + } + sql.append(" AND UPPER(").append(column).append(") LIKE ? ESCAPE '\\\\'"); + args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + + private static void appendLimitOffset(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasLimit()) { + return; + } + sql.append(" LIMIT ?"); + args.add(constraints.getLimit()); + if (constraints.hasOffset()) { + sql.append(" OFFSET ?"); + args.add(constraints.getOffset()); + } + } + + private static boolean hasSchema(String schema) { + return schema != null && !schema.trim().isEmpty(); + } + + private static boolean includesRoutineTypes(MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return true; + } + return constraints.objectTypeAllowed("PROCEDURE") || constraints.objectTypeAllowed("FUNCTION"); + } + + private static List tableLikeObjectTypes(MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return null; + } + List result = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + result.add("TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + result.add("VIEW"); + } + return result; + } + + private static List routineObjectTypes(MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return null; + } + List result = new ArrayList<>(); + if (constraints.objectTypeAllowed("PROCEDURE")) { + result.add("PROCEDURE"); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + result.add("FUNCTION"); + } + return result; + } + + private static String placeholders(int count) { + return String.join(", ", java.util.Collections.nCopies(count, "?")); + } + + private static void bind(PreparedStatement stmt, List args) throws Exception { + for (int index = 0; index < args.size(); index += 1) { + Object arg = args.get(index); + if (arg instanceof Integer) { + stmt.setInt(index + 1, (Integer) arg); + } else { + stmt.setString(index + 1, String.valueOf(arg)); + } + } + } + + private static String normalizeRoutineType(String routineType) { + if (routineType == null || routineType.trim().isEmpty()) { + return "PROCEDURE"; + } + return routineType.trim().toUpperCase(Locale.ROOT); + } + + private static ExecuteQueryOptions withoutFetchSize(ExecuteQueryOptions options) { + return new ExecuteQueryOptions(options.getMaxRows(), null, options.getTimeoutSecs()); + } + + private static QueryPageOptions withoutFetchSize(QueryPageOptions options) { + return new QueryPageOptions(options.getPageSize(), null, options.getMaxRows(), options.getTimeoutSecs()); + } + + public static void main(String[] args) { + new JsonRpcServer(new Gbase8aAgent()).run(); + } +} diff --git a/agents/drivers/gbase8a/src/test/java/com/dbx/agent/gbase8a/Gbase8aAgentTest.java b/agents/drivers/gbase8a/src/test/java/com/dbx/agent/gbase8a/Gbase8aAgentTest.java new file mode 100644 index 0000000..e17769d --- /dev/null +++ b/agents/drivers/gbase8a/src/test/java/com/dbx/agent/gbase8a/Gbase8aAgentTest.java @@ -0,0 +1,265 @@ +package com.dbx.agent.gbase8a; + +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +class Gbase8aAgentTest { + @Test + void listObjectsIncludesRoutinesFromInformationSchema() { + List sql = new ArrayList<>(); + Gbase8aAgent agent = new Gbase8aAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"TABLE_NAME", "TABLE_TYPE"}, + new Object[][]{{"orders", "BASE TABLE"}} + ), + resultSet( + new String[]{"ROUTINE_NAME", "ROUTINE_TYPE", "ROUTINE_COMMENT"}, + new Object[][]{{"refresh_orders", "PROCEDURE", "proc comment"}, {"format_order", "FUNCTION", null}} + ) + )); + + List objects = agent.listObjects("app"); + + Assertions.assertEquals(3, objects.size()); + Assertions.assertEquals("orders", objects.get(0).getName()); + Assertions.assertEquals("TABLE", objects.get(0).getObject_type()); + Assertions.assertEquals("refresh_orders", objects.get(1).getName()); + Assertions.assertEquals("PROCEDURE", objects.get(1).getObject_type()); + Assertions.assertEquals("app", objects.get(1).getSchema()); + Assertions.assertEquals("proc comment", objects.get(1).getComment()); + Assertions.assertEquals("format_order", objects.get(2).getName()); + Assertions.assertEquals("FUNCTION", objects.get(2).getObject_type()); + Assertions.assertEquals("app", objects.get(2).getSchema()); + Assertions.assertTrue(sql.get(1).contains("FROM information_schema.ROUTINES"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("ROUTINE_SCHEMA = ?"), sql.get(1)); + } + + @Test + void constrainedListTablesPushesFilterTypeAndLimit() { + List sql = new ArrayList<>(); + Gbase8aAgent agent = new Gbase8aAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"TABLE_NAME", "TABLE_TYPE"}, + new Object[][]{{"user_order", "BASE TABLE"}} + ) + )); + + List tables = agent.listTables( + "app", + new MetadataListConstraints("user", 1, 1, List.of("TABLE")) + ); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("user_order", tables.get(0).getName()); + Assertions.assertTrue(sql.get(0).contains("TABLE_TYPE IN (?)"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(TABLE_NAME) LIKE ?"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("LIMIT ?"), sql.get(0)); + } + + @Test + void constrainedListObjectsKeepsCustomRoutineMetadata() { + List sql = new ArrayList<>(); + Gbase8aAgent agent = new Gbase8aAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"ROUTINE_NAME", "ROUTINE_TYPE", "ROUTINE_COMMENT"}, + new Object[][]{{"refresh_orders", "PROCEDURE", "proc comment"}, {"format_order", "FUNCTION", null}} + ) + )); + + List objects = agent.listObjects( + "app", + new MetadataListConstraints("format", 1, null, List.of("FUNCTION")) + ); + + Assertions.assertEquals(1, objects.size()); + Assertions.assertEquals("format_order", objects.get(0).getName()); + Assertions.assertEquals("FUNCTION", objects.get(0).getObject_type()); + Assertions.assertTrue(sql.get(0).contains("ROUTINE_TYPE IN (?)"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(ROUTINE_NAME) LIKE ?"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("LIMIT ?"), sql.get(0)); + } + + @Test + void getObjectSourceUsesShowCreateForRoutines() { + List sql = new ArrayList<>(); + Gbase8aAgent agent = new Gbase8aAgent(); + TestSupport.setPrivateConnection(agent, statementConnection(sql, resultSet( + new String[]{"Procedure", "sql_mode", "Create Procedure"}, + new Object[][]{{"refresh_orders", "", "CREATE PROCEDURE `refresh_orders`() SELECT 1"}} + ))); + + ObjectSource source = agent.getObjectSource("app", "refresh_orders", "procedure"); + + Assertions.assertEquals("PROCEDURE", source.getObject_type()); + Assertions.assertEquals("app", source.getSchema()); + Assertions.assertEquals("CREATE PROCEDURE `refresh_orders`() SELECT 1", source.getSource()); + Assertions.assertEquals("SHOW CREATE PROCEDURE `app`.`refresh_orders`", sql.get(0)); + } + + @Test + void getObjectSourceFindsDefinitionAcrossDriverResultLayouts() { + List sql = new ArrayList<>(); + Gbase8aAgent agent = new Gbase8aAgent(); + TestSupport.setPrivateConnection(agent, statementConnection(sql, resultSet( + new String[]{"Function", "Create Function", "character_set_client", "Database Collation"}, + new Object[][]{{"format_order", "CREATE FUNCTION `format_order`() RETURNS INT RETURN 1", "utf8", "utf8_bin"}} + ))); + + ObjectSource source = agent.getObjectSource("app`prod", "format`order", "FUNCTION"); + + Assertions.assertEquals("CREATE FUNCTION `format_order`() RETURNS INT RETURN 1", source.getSource()); + Assertions.assertEquals("SHOW CREATE FUNCTION `app``prod`.`format``order`", sql.get(0)); + } + + @Test + void getObjectSourceSupportsViewsAndRejectsOtherObjectTypes() { + List sql = new ArrayList<>(); + Gbase8aAgent agent = new Gbase8aAgent(); + TestSupport.setPrivateConnection(agent, statementConnection(sql, resultSet( + new String[]{"View", "Create View"}, + new Object[][]{{"active_orders", "CREATE VIEW `active_orders` AS SELECT 1"}} + ))); + + ObjectSource source = agent.getObjectSource("app", "active_orders", "VIEW"); + + Assertions.assertEquals("CREATE VIEW `active_orders` AS SELECT 1", source.getSource()); + Assertions.assertEquals("SHOW CREATE VIEW `app`.`active_orders`", sql.get(0)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> agent.getObjectSource("app", "orders", "TABLE") + ); + } + + private static Connection preparedConnection(List sql, ResultSet... resultSets) { + int[] resultSetIndex = {0}; + PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("setString".equals(method.getName()) || "setInt".equals(method.getName()) || "close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection statementConnection(List sql, ResultSet resultSet) { + Statement statement = proxy(Statement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return resultSet; + } + if ("close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("createStatement".equals(method.getName())) { + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet resultSet(String[] columns, Object[][] rows) { + int[] index = {-1}; + ResultSetMetaData metadata = proxy(ResultSetMetaData.class, (method, args) -> { + if ("getColumnCount".equals(method.getName())) { + return columns.length; + } + if ("getColumnLabel".equals(method.getName())) { + return columns[((Number) args[0]).intValue() - 1]; + } + return defaultValue(method.getReturnType()); + }); + return proxy(ResultSet.class, (method, args) -> { + switch (method.getName()) { + case "next": + index[0] += 1; + return index[0] < rows.length; + case "getString": + Object value = columnValue(columns, rows[index[0]], args[0]); + return value == null ? null : String.valueOf(value); + case "getMetaData": + return metadata; + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static Object columnValue(String[] columns, Object[] row, Object key) { + if (key instanceof Number) { + return row[((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return row[i]; + } + } + return null; + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) return false; + if (type == Byte.TYPE) return (byte) 0; + if (type == Short.TYPE) return (short) 0; + if (type == Integer.TYPE) return 0; + if (type == Long.TYPE) return 0L; + if (type == Float.TYPE) return 0f; + if (type == Double.TYPE) return 0d; + if (type == Character.TYPE) return (char) 0; + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/gbase8s/build.gradle b/agents/drivers/gbase8s/build.gradle new file mode 100644 index 0000000..bc2f0fd --- /dev/null +++ b/agents/drivers/gbase8s/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation 'com.gbasedbt:jdbc:3.6.3.2' +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'GBase 8s', + 'Main-Class': 'com.dbx.agent.gbase8s.Gbase8sAgent' + ) + } +} diff --git a/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java b/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java new file mode 100644 index 0000000..52cdd7c --- /dev/null +++ b/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java @@ -0,0 +1,859 @@ +package com.dbx.agent.gbase8s; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class Gbase8sAgent extends ConfiguredJdbcAgent { + private static final long METADATA_CACHE_TTL_MILLIS = 10_000L; + + public static final JdbcAgentProfile GBASE8S_PROFILE = new JdbcAgentProfile( + "com.gbasedbt.jdbc.Driver", + "jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s", + 9088, + true + ); + + private final Object metadataCacheLock = new Object(); + private long databaseCacheTimeMillis; + private List databaseCache = Collections.emptyList(); + private String schemaCacheCatalog = ""; + private long schemaCacheTimeMillis; + private List schemaCache = Collections.emptyList(); + private String tableCacheCatalog = ""; + private String tableCacheSchema = ""; + private long tableCacheTimeMillis; + private List tableCache = Collections.emptyList(); + + public Gbase8sAgent() { + super(GBASE8S_PROFILE); + } + + public static String buildUrl(ConnectParams params) { + if (!params.getConnection_string().trim().isEmpty()) { + return params.getConnection_string(); + } + String extraParams = trimEnd(trimStart(params.getUrl_params().trim(), ':', ';'), ';'); + String database = params.getDatabase().trim().isEmpty() ? "sysmaster" : params.getDatabase().trim(); + String serverParam = containsIgnoreCase(extraParams, "GBASEDBTSERVER=") + ? "" + : "GBASEDBTSERVER=" + getGbaseServer(params); + List jdbcParams = new ArrayList<>(); + if (!serverParam.isBlank()) { + jdbcParams.add(serverParam); + } + if (!extraParams.isBlank()) { + jdbcParams.add(extraParams); + } + return "jdbc:gbasedbt-sqli://" + params.getHost() + ":" + port(params) + "/" + database + ":" + + String.join(";", jdbcParams); + } + + private static String getGbaseServer(ConnectParams params) { + if (params.getGbase_server() != null && !params.getGbase_server().trim().isEmpty()) { + return params.getGbase_server().trim(); + } + return defaultGbaseServer(params.getHost()); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) { + super.afterConnect(params, connection); + clearMetadataCache(); + } + + @Override + public void disconnect() { + clearMetadataCache(); + super.disconnect(); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + QueryResult result = super.executeQuery(sql, schema, options); + if (mayChangeMetadata(sql)) { + clearMetadataCache(); + } + return result; + } + + @Override + public List listDatabases() { + List cached = cachedDatabases(); + if (cached != null) { + return cached; + } + List names = queryDatabaseNamesInCatalog("sysmaster", "SELECT name FROM sysdatabases ORDER BY name"); + if (names.isEmpty()) { + names = queryDatabaseNames("SELECT name FROM sysmaster:sysdatabases ORDER BY name"); + } + if (names.isEmpty()) { + names = queryDatabaseNames("SELECT name FROM sysdatabases ORDER BY name"); + } + if (names.isEmpty()) { + return super.listDatabases(); + } + List result = new ArrayList<>(); + for (String name : names) { + result.add(new DatabaseInfo(name)); + } + cacheDatabases(result); + return result; + } + + @Override + public List listSchemas() { + try { + String catalog = currentCatalog(); + List cached = cachedSchemas(catalog); + if (cached != null) { + return cached; + } + Set schemas = new LinkedHashSet<>(); + try (PreparedStatement stmt = requireConnection().prepareStatement( + "SELECT DISTINCT owner FROM systables WHERE tabid >= 100 AND tabtype IN ('T', 'V') ORDER BY owner" + ); ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String owner = trim(rs.getString("owner")); + if (!owner.isEmpty()) { + schemas.add(owner); + } + } + } + List result = new ArrayList<>(schemas); + Collections.sort(result); + cacheSchemas(catalog, result); + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public List listTables(String schema) { + try { + String catalog = currentCatalog(); + List cached = cachedTables(catalog, schema); + if (cached != null) { + return cached; + } + List result = new ArrayList<>(); + String owner = trim(schema); + String sql = """ + SELECT t.tabname, t.tabtype, c.comments + FROM systables t + LEFT JOIN syscomms c ON c.tabid = t.tabid + WHERE t.tabid >= 100 AND t.tabtype IN ('T', 'V') + """.stripIndent().trim(); + if (!owner.isEmpty()) { + sql += " AND t.owner = ?"; + } + sql += " ORDER BY tabname"; + try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) { + if (!owner.isEmpty()) { + stmt.setString(1, owner); + } + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + trim(rs.getString("tabname")), + tableType(rs.getString("tabtype")), + emptyToNull(trim(rs.getString("comments"))) + )); + } + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + cacheTables(catalog, schema, result); + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + return queryConstrainedTables(schema, normalized); + } + + @Override + public List getColumns(String schema, String table) { + try { + Connection conn = requireConnection(); + String owner = trim(schema); + Set primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, owner, table); + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder(""" + SELECT c.colname, c.coltype, c.colno, c.collength, cc.comments + FROM syscolumns c + JOIN systables t ON t.tabid = c.tabid + LEFT JOIN syscolcomms cc ON cc.tabid = c.tabid AND cc.colno = c.colno + WHERE t.tabid >= 100 AND t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + sql.append(" ORDER BY c.colno"); + + List result = new ArrayList<>(); + try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = trim(rs.getString("colname")); + int coltype = rs.getInt("coltype"); + int baseType = baseColType(coltype); + int length = rs.getInt("collength"); + result.add(new ColumnInfo( + name, + mapColType(baseType), + (coltype & 256) == 0, + null, + primaryKeyColumns.contains(rs.getInt("colno")), + null, + emptyToNull(trim(rs.getString("comments"))), + numericPrecision(baseType, length), + numericScale(baseType, length), + characterMaximumLength(baseType, length) + )); + } + } + } + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public List listIndexes(String schema, String table) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder(""" + SELECT i.idxname, i.idxtype, c.constrtype, + i.part1, i.part2, i.part3, i.part4, i.part5, i.part6, i.part7, i.part8, + i.part9, i.part10, i.part11, i.part12, i.part13, i.part14, i.part15, i.part16 + FROM sysindexes i + JOIN systables t ON t.tabid = i.tabid + LEFT JOIN sysconstraints c ON c.tabid = i.tabid AND c.idxname = i.idxname + WHERE t.tabid >= 100 AND t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + sql.append(" ORDER BY i.idxname"); + + Map columnNames = loadColumnNamesByNumber(owner, table); + List result = new ArrayList<>(); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = trim(rs.getString("idxname")); + if (name.isEmpty()) { + continue; + } + List parts = readIndexParts(rs); + List columns = resolveIndexColumns(parts, columnNames); + String indexType = trim(rs.getString("idxtype")); + String constraintType = trim(rs.getString("constrtype")); + result.add(new IndexInfo( + name, + columns, + indexType.toUpperCase(Locale.ROOT).startsWith("U"), + "P".equalsIgnoreCase(constraintType), + null, + indexType, + null, + null + )); + } + } + } + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT); + if (!"VIEW".equals(normalizedType)) { + throw new UnsupportedOperationException("Object source is not supported"); + } + return new ObjectSource(name, "VIEW", emptyToNull(trim(schema)), viewSource(schema, name), isEditableView(schema, name)); + } + + @Override + public String getTableDdl(String schema, String table) { + if ("VIEW".equals(tableType(schema, table))) { + return viewSource(schema, table); + } + return super.getTableDdl(schema, table); + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + if (!constraints.includesTableLikeTypes()) { + return List.of(); + } + try { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + String owner = trim(schema); + // GBase 8s follows Informix-style SKIP/FIRST pagination in the SELECT list. + StringBuilder sql = new StringBuilder("SELECT "); + if (constraints.hasOffset()) { + sql.append("SKIP ").append(constraints.getOffset()).append(' '); + } + if (constraints.hasLimit()) { + sql.append("FIRST ").append(constraints.getLimit()).append(' '); + } + sql.append("t.tabname, t.tabtype, c.comments FROM systables t LEFT JOIN syscomms c ON c.tabid = t.tabid WHERE t.tabid >= 100"); + appendGbase8sTableTypePredicate(sql, constraints); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + if (constraints.hasFilter()) { + sql.append(" AND UPPER(t.tabname) LIKE ? ESCAPE '\\\\'"); + args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + sql.append(" ORDER BY t.tabname"); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + trim(rs.getString("tabname")), + tableType(rs.getString("tabtype")), + emptyToNull(trim(rs.getString("comments"))) + )); + } + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + return constraints.withoutPaging().filterTables(result); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static void main(String[] args) { + new JsonRpcServer(new Gbase8sAgent()).run(); + } + + private static int port(ConnectParams params) { + return params.getPort() > 0 ? params.getPort() : GBASE8S_PROFILE.getDefaultPort(); + } + + private static String defaultGbaseServer(String host) { + return isIpAddress(host) ? "gbase8s" : host; + } + + private static boolean isIpAddress(String host) { + return host.matches("\\d{1,3}(\\.\\d{1,3}){3}") || host.contains(":"); + } + + private static String trimStart(String value, char... chars) { + int start = 0; + while (start < value.length() && contains(chars, value.charAt(start))) { + start++; + } + return value.substring(start); + } + + private static String trimEnd(String value, char... chars) { + int end = value.length(); + while (end > 0 && contains(chars, value.charAt(end - 1))) { + end--; + } + return value.substring(0, end); + } + + private static boolean contains(char[] chars, char value) { + for (char ch : chars) { + if (ch == value) { + return true; + } + } + return false; + } + + private static boolean containsIgnoreCase(String value, String needle) { + return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT)); + } + + private List queryDatabaseNames(String sql) { + try { + return queryDatabaseNames(requireConnection(), sql); + } catch (Exception ignored) { + return Collections.emptyList(); + } + } + + private List queryDatabaseNamesInCatalog(String catalog, String sql) { + try { + Connection connection = requireConnection(); + String previousCatalog = ""; + try { + previousCatalog = trim(connection.getCatalog()); + } catch (Exception ignored) { + } + connection.setCatalog(catalog); + try { + return queryDatabaseNames(connection, sql); + } finally { + if (!previousCatalog.isEmpty()) { + try { + connection.setCatalog(previousCatalog); + } catch (Exception ignored) { + } + } + } + } catch (Exception ignored) { + return Collections.emptyList(); + } + } + + private static List queryDatabaseNames(Connection connection, String sql) throws Exception { + Set names = new LinkedHashSet<>(); + try (PreparedStatement stmt = connection.prepareStatement(sql); ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = trim(rs.getString(1)); + if (!name.isEmpty()) { + names.add(name); + } + } + } + List result = new ArrayList<>(names); + Collections.sort(result); + return result; + } + + private static String tableType(String tabtype) { + return "V".equalsIgnoreCase(trim(tabtype)) ? "VIEW" : "TABLE"; + } + + private String tableType(String schema, String table) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder("SELECT tabtype FROM systables WHERE tabid >= 100 AND tabname = ?"); + if (!owner.isEmpty()) { + sql.append(" AND owner = ?"); + args.add(owner); + } + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? tableType(rs.getString("tabtype")) : ""; + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private String viewSource(String schema, String name) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(name); + StringBuilder sql = new StringBuilder(""" + SELECT v.viewtext + FROM sysviews v + JOIN systables t ON t.tabid = v.tabid + WHERE t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + sql.append(" ORDER BY v.seqno"); + StringBuilder source = new StringBuilder(); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String chunk = rs.getString("viewtext"); + source.append(chunk == null ? "" : chunk); + } + } + } + String result = stripTrailing(source.toString()); + if (result.isEmpty()) { + throw new IllegalArgumentException("View source not found: " + name); + } + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isEditableView(String schema, String name) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(name); + StringBuilder sql = new StringBuilder(""" + SELECT t.tabid, t.owner, + (SELECT v.tabid FROM systables v WHERE UPPER(TRIM(v.tabname)) = 'VERSION') AS system_boundary_tabid + FROM systables t + WHERE t.tabtype = 'V' AND t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return true; + } + int systemBoundaryTabid = rs.getInt("system_boundary_tabid"); + Integer boundary = rs.wasNull() ? null : systemBoundaryTabid; + return !isSystemCatalogView(rs.getInt("tabid"), rs.getString("owner"), boundary); + } + } + } catch (Exception ignored) { + return true; + } + } + + private static boolean isSystemCatalogView(int tabid, String owner, Integer systemBoundaryTabid) { + if (!"gbasedbt".equalsIgnoreCase(trim(owner))) { + return false; + } + // GBase 8s marks system catalog tables before/at VERSION; avoid a fixed tabid threshold + // so ordinary gbasedbt-owned user views are still editable. + if (systemBoundaryTabid != null && systemBoundaryTabid > 0) { + return tabid <= systemBoundaryTabid; + } + return tabid < 100; + } + + public static String mapColType(int coltype) { + return switch (baseColType(coltype)) { + case 0 -> "CHAR"; + case 1 -> "SMALLINT"; + case 2 -> "INTEGER"; + case 3 -> "FLOAT"; + case 4 -> "SMALLFLOAT"; + case 5 -> "DECIMAL"; + case 6 -> "SERIAL"; + case 7 -> "DATE"; + case 8 -> "MONEY"; + case 9 -> "NULL"; + case 10 -> "DATETIME"; + case 11 -> "BYTE"; + case 12 -> "TEXT"; + case 13 -> "VARCHAR"; + case 14 -> "INTERVAL"; + case 15 -> "NCHAR"; + case 16 -> "NVARCHAR"; + case 17 -> "INT8"; + case 18 -> "SERIAL8"; + case 19 -> "SET"; + case 20 -> "MULTISET"; + case 21 -> "LIST"; + case 22 -> "ROW"; + case 23 -> "COLLECTION"; + case 40 -> "LVARCHAR"; + case 41 -> "BOOLEAN"; + case 43, 52 -> "BIGINT"; + case 44, 53 -> "BIGSERIAL"; + default -> "UNKNOWN(" + baseColType(coltype) + ")"; + }; + } + + public static Set primaryKeyColumnNumbers(List parts) { + Set result = new HashSet<>(); + for (Integer part : parts) { + if (part == null) { + continue; + } + int value = Math.abs(part); + if (value > 0) { + result.add(value); + } + } + return result; + } + + static List resolveIndexColumns(List parts, Map columnNames) { + List result = new ArrayList<>(); + for (Integer part : parts) { + if (part == null || part == 0) { + continue; + } + // Informix-compatible catalogs encode descending index columns as negative column numbers. + String columnName = columnNames.get(Math.abs(part)); + if (columnName != null && !columnName.isEmpty()) { + result.add(columnName); + } + } + return result; + } + + private Map loadColumnNamesByNumber(String owner, String table) throws Exception { + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder(""" + SELECT c.colno, c.colname + FROM syscolumns c + JOIN systables t ON t.tabid = c.tabid + WHERE t.tabid >= 100 AND t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + + Map result = new LinkedHashMap<>(); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.put(rs.getInt("colno"), trim(rs.getString("colname"))); + } + } + } + return result; + } + + private static List readIndexParts(ResultSet rs) throws Exception { + List parts = new ArrayList<>(); + for (int index = 1; index <= 16; index += 1) { + int value = rs.getInt("part" + index); + parts.add(rs.wasNull() ? null : value); + } + return parts; + } + + private Set getPrimaryKeyColumnNumbers(Connection conn, String owner, String table) throws Exception { + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder(""" + SELECT i.part1, i.part2, i.part3, i.part4, i.part5, i.part6, i.part7, i.part8, + i.part9, i.part10, i.part11, i.part12, i.part13, i.part14, i.part15, i.part16 + FROM sysconstraints c + JOIN sysindexes i ON i.idxname = c.idxname AND i.tabid = c.tabid + JOIN systables t ON t.tabid = c.tabid + WHERE t.tabname = ? AND c.constrtype = 'P' + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return Collections.emptySet(); + } + List parts = new ArrayList<>(); + for (int index = 1; index <= 16; index += 1) { + int value = rs.getInt(index); + parts.add(rs.wasNull() ? null : value); + } + return primaryKeyColumnNumbers(parts); + } + } + } + + private static int baseColType(int coltype) { + return coltype % 256; + } + + private static Integer numericPrecision(int baseType, int length) { + if (baseType == 5 || baseType == 8) { + return (length >> 8) & 0xff; + } + return null; + } + + private static Integer numericScale(int baseType, int length) { + if (baseType == 5 || baseType == 8) { + return length & 0xff; + } + return null; + } + + private static Integer characterMaximumLength(int baseType, int length) { + return switch (baseType) { + case 0, 13, 15, 16, 40 -> length; + default -> null; + }; + } + + private static void appendGbase8sTableTypePredicate(StringBuilder sql, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + sql.append(" AND tabtype IN ('T', 'V')"); + return; + } + List tabTypes = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + tabTypes.add("'T'"); + } + if (constraints.tableTypeAllowed("VIEW")) { + tabTypes.add("'V'"); + } + if (tabTypes.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND tabtype IN (").append(String.join(", ", tabTypes)).append(")"); + } + + private static void bind(PreparedStatement stmt, List args) throws Exception { + for (int index = 0; index < args.size(); index += 1) { + stmt.setString(index + 1, String.valueOf(args.get(index))); + } + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() + && !constraints.hasLimit() + && !constraints.hasOffset() + && !constraints.hasObjectTypes(); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String emptyToNull(String value) { + return value.isEmpty() ? null : value; + } + + private static String stripTrailing(String value) { + return value == null ? "" : value.stripTrailing(); + } + + private String currentCatalog() { + try { + return trim(requireConnection().getCatalog()); + } catch (Exception ignored) { + return ""; + } + } + + private List cachedDatabases() { + synchronized (metadataCacheLock) { + if (cacheFresh(databaseCacheTimeMillis) && !databaseCache.isEmpty()) { + return new ArrayList<>(databaseCache); + } + } + return null; + } + + private void cacheDatabases(List databases) { + synchronized (metadataCacheLock) { + databaseCache = new ArrayList<>(databases); + databaseCacheTimeMillis = System.currentTimeMillis(); + } + } + + private List cachedSchemas(String catalog) { + synchronized (metadataCacheLock) { + if (cacheFresh(schemaCacheTimeMillis) && schemaCacheCatalog.equals(catalog)) { + return new ArrayList<>(schemaCache); + } + } + return null; + } + + private void cacheSchemas(String catalog, List schemas) { + synchronized (metadataCacheLock) { + schemaCacheCatalog = catalog; + schemaCache = new ArrayList<>(schemas); + schemaCacheTimeMillis = System.currentTimeMillis(); + } + } + + private List cachedTables(String catalog, String schema) { + String owner = trim(schema); + synchronized (metadataCacheLock) { + if (cacheFresh(tableCacheTimeMillis) && tableCacheCatalog.equals(catalog) && tableCacheSchema.equals(owner)) { + return new ArrayList<>(tableCache); + } + } + return null; + } + + private void cacheTables(String catalog, String schema, List tables) { + synchronized (metadataCacheLock) { + tableCacheCatalog = catalog; + tableCacheSchema = trim(schema); + tableCache = new ArrayList<>(tables); + tableCacheTimeMillis = System.currentTimeMillis(); + } + } + + private boolean cacheFresh(long timeMillis) { + return timeMillis > 0 && System.currentTimeMillis() - timeMillis <= METADATA_CACHE_TTL_MILLIS; + } + + private void clearMetadataCache() { + synchronized (metadataCacheLock) { + databaseCacheTimeMillis = 0; + databaseCache = Collections.emptyList(); + schemaCacheCatalog = ""; + schemaCacheTimeMillis = 0; + schemaCache = Collections.emptyList(); + tableCacheCatalog = ""; + tableCacheSchema = ""; + tableCacheTimeMillis = 0; + tableCache = Collections.emptyList(); + } + } + + private static boolean mayChangeMetadata(String sql) { + String normalized = trim(sql).toLowerCase(Locale.ROOT); + return normalized.startsWith("create ") + || normalized.startsWith("drop ") + || normalized.startsWith("alter ") + || normalized.startsWith("rename ") + || normalized.startsWith("truncate "); + } +} diff --git a/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java b/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java new file mode 100644 index 0000000..2b3e6d7 --- /dev/null +++ b/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java @@ -0,0 +1,439 @@ +package com.dbx.agent.gbase8s; + +import com.dbx.agent.ConnectParams; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +class Gbase8sAgentTest { + @Test + void declaresGbase8sProfile() { + Gbase8sAgent agent = new Gbase8sAgent(); + + Assertions.assertEquals("com.gbasedbt.jdbc.Driver", agent.getProfile().getDriverClass()); + Assertions.assertEquals("jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s", agent.getProfile().getUrlTemplate()); + Assertions.assertEquals(9088, agent.getProfile().getDefaultPort()); + Assertions.assertTrue(agent.getProfile().getSkipExecutionContext()); + } + + @Test + void buildsGbase8sJdbcUrlWithExplicitServerAndLocaleParameters() { + String url = Gbase8sAgent.buildUrl( + new ConnectParams( + "172.26.128.159", + 20013, + "testdb", + "", + "", + "GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8;DB_LOCALE=zh_cn.utf8", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:gbasedbt-sqli://172.26.128.159:20013/testdb:GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_cn.utf8;DB_LOCALE=zh_cn.utf8", + url + ); + } + + @Test + void fallsBackToHostAsGbaseServerWhenNoExplicitServerIsConfigured() { + String url = Gbase8sAgent.buildUrl( + new ConnectParams( + "gbase-host", + 9088, + "sysmaster", + "", + "", + "", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:gbasedbt-sqli://gbase-host:9088/sysmaster:GBASEDBTSERVER=gbase-host", + url + ); + } + + @Test + void fallsBackToGbaseServerNameWhenHostIsAnIpAddress() { + String url = Gbase8sAgent.buildUrl( + new ConnectParams( + "172.26.128.159", + 0, + "sysmaster", + "", + "", + "", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:gbasedbt-sqli://172.26.128.159:9088/sysmaster:GBASEDBTSERVER=gbase8s", + url + ); + } + + @Test + void usesConnectionStringWhenConfigured() { + String url = Gbase8sAgent.buildUrl( + new ConnectParams( + "ignored", + 0, + "", + "", + "", + "", + "jdbc:gbasedbt-sqli://db.example.com:20013/app:GBASEDBTSERVER=gbase01", + false + ) + ); + + Assertions.assertEquals("jdbc:gbasedbt-sqli://db.example.com:20013/app:GBASEDBTSERVER=gbase01", url); + } + + @Test + void constrainedListTablesUsesGbase8sSystemTableQuery() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"tabname", "tabtype"}, + new Object[][]{ + {"user_order", "T"} + } + ))); + + List tables = agent.listTables( + "app", + new MetadataListConstraints("user", 1, 1, List.of("TABLE")) + ); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("user_order", tables.get(0).getName()); + Assertions.assertTrue(sql.get(0).contains("FROM systables"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("SELECT SKIP 1 FIRST 1"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(t.tabname) LIKE ?"), sql.get(0)); + } + + @Test + void listTablesLoadsGbase8sTableComments() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"tabname", "tabtype", "comments"}, + new Object[][]{ + {"products", "T", "Product catalog"} + } + ))); + + List tables = agent.listTables("root"); + + Assertions.assertEquals("Product catalog", tables.get(0).getComment()); + Assertions.assertTrue(sql.get(0).contains("LEFT JOIN syscomms"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0)); + } + + @Test + void extractsPrimaryKeyColumnNumbersFromGbase8sIndexParts() { + Assertions.assertEquals( + Set.of(1, 3, 5), + Gbase8sAgent.primaryKeyColumnNumbers(Arrays.asList(1, -3, 0, 5, null)) + ); + } + + @Test + void getColumnsUsesGbase8sSystemCatalog() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"part1", "part2", "part3", "part4", "part5", "part6", "part7", "part8", "part9", "part10", "part11", "part12", "part13", "part14", "part15", "part16"}, + new Object[][]{ + {1, 0, null, null, null, null, null, null, null, null, null, null, null, null, null, null} + } + ), + resultSet( + new String[]{"colname", "coltype", "colno", "collength", "comments"}, + new Object[][]{ + {"product_id", 258, 1, 4, "Product identifier"}, + {"sku", 13, 2, 40, null}, + {"price", 5, 3, 3074, "Unit price"} + } + ) + )); + + List columns = agent.getColumns("root", "products"); + + Assertions.assertEquals(2, sql.size()); + Assertions.assertTrue(sql.get(0).contains("FROM sysconstraints"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("FROM syscolumns"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("t.owner = ?"), sql.get(1)); + Assertions.assertEquals(3, columns.size()); + Assertions.assertEquals("product_id", columns.get(0).getName()); + Assertions.assertEquals("INTEGER", columns.get(0).getData_type()); + Assertions.assertFalse(columns.get(0).getIs_nullable()); + Assertions.assertTrue(columns.get(0).getIs_primary_key()); + Assertions.assertEquals("VARCHAR", columns.get(1).getData_type()); + Assertions.assertEquals(40, columns.get(1).getCharacter_maximum_length()); + Assertions.assertEquals("DECIMAL", columns.get(2).getData_type()); + Assertions.assertEquals(12, columns.get(2).getNumeric_precision()); + Assertions.assertEquals(2, columns.get(2).getNumeric_scale()); + Assertions.assertEquals("Product identifier", columns.get(0).getComment()); + Assertions.assertEquals("Unit price", columns.get(2).getComment()); + Assertions.assertTrue(sql.get(1).contains("LEFT JOIN syscolcomms"), sql.get(1)); + } + + @Test + void listIndexesLoadsGbase8sSystemCatalogIndexes() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"colno", "colname"}, + new Object[][]{ + {1, "product_id"}, + {2, "sku"}, + {3, "created_at"} + } + ), + resultSet( + new String[]{"idxname", "idxtype", "constrtype", "part1", "part2", "part3", "part4", "part5", "part6", "part7", "part8", "part9", "part10", "part11", "part12", "part13", "part14", "part15", "part16"}, + new Object[][]{ + {"products_pk", "U", "P", 1, 0, null, null, null, null, null, null, null, null, null, null, null, null, null, null}, + {"products_sku_created", "D", null, 2, -3, 0, null, null, null, null, null, null, null, null, null, null, null, null, null} + } + ) + )); + + List indexes = agent.listIndexes("root", "products"); + + Assertions.assertEquals(2, indexes.size()); + Assertions.assertEquals(List.of("product_id"), indexes.get(0).getColumns()); + Assertions.assertTrue(indexes.get(0).getIs_unique()); + Assertions.assertTrue(indexes.get(0).getIs_primary()); + Assertions.assertEquals(List.of("sku", "created_at"), indexes.get(1).getColumns()); + Assertions.assertFalse(indexes.get(1).getIs_unique()); + Assertions.assertFalse(indexes.get(1).getIs_primary()); + Assertions.assertTrue(sql.get(0).contains("FROM syscolumns"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("FROM sysindexes"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("LEFT JOIN sysconstraints"), sql.get(1)); + } + + @Test + void resolvesGbase8sIndexPartsInDeclaredOrder() { + Assertions.assertEquals( + List.of("sku", "created_at"), + Gbase8sAgent.resolveIndexColumns(List.of(2, -3, 0), Map.of(2, "sku", 3, "created_at")) + ); + } + + @Test + void getObjectSourceUsesGbase8sViewCatalog() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"viewtext"}, + new Object[][]{ + {"create view \"gbasedbt\".demo_view as select "}, + {"* from products; "} + } + ), + resultSet( + new String[]{"tabid", "owner", "system_boundary_tabid"}, + new Object[][]{ + {1000, "gbasedbt", 614} + } + ) + )); + + ObjectSource source = agent.getObjectSource("gbasedbt", "demo_view", "VIEW"); + + Assertions.assertEquals("demo_view", source.getName()); + Assertions.assertEquals("VIEW", source.getObject_type()); + Assertions.assertEquals("gbasedbt", source.getSchema()); + Assertions.assertEquals("create view \"gbasedbt\".demo_view as select * from products;", source.getSource()); + Assertions.assertTrue(source.isEditable()); + Assertions.assertEquals(2, sql.size()); + Assertions.assertTrue(sql.get(0).contains("FROM sysviews"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("ORDER BY v.seqno"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("system_boundary_tabid"), sql.get(1)); + } + + @Test + void getObjectSourceMarksGbase8sSystemViewsReadOnly() { + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + new ArrayList<>(), + resultSet( + new String[]{"viewtext"}, + new Object[][]{ + {"create view \"gbasedbt\".dba_db_links as select * from user_db_links;"} + } + ), + resultSet( + new String[]{"tabid", "owner", "system_boundary_tabid"}, + new Object[][]{ + {614, "gbasedbt", 614} + } + ) + )); + + ObjectSource source = agent.getObjectSource("gbasedbt", "dba_db_links", "VIEW"); + + Assertions.assertFalse(source.isEditable()); + } + + @Test + void getTableDdlReturnsViewSourceForGbase8sViews() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"tabtype"}, + new Object[][]{ + {"V"} + } + ), + resultSet( + new String[]{"viewtext"}, + new Object[][]{ + {"create view demo_view as select 1 as id; "} + } + ) + )); + + String ddl = agent.getTableDdl("gbasedbt", "demo_view"); + + Assertions.assertEquals("create view demo_view as select 1 as id;", ddl); + Assertions.assertEquals(2, sql.size()); + Assertions.assertTrue(sql.get(0).contains("SELECT tabtype FROM systables"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("FROM sysviews"), sql.get(1)); + } + + private static Connection preparedConnection(List sql, ResultSet... resultSets) { + int[] resultIndex = {0}; + PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + int index = Math.min(resultIndex[0], resultSets.length - 1); + resultIndex[0] += 1; + return resultSets[index]; + } + if ("setString".equals(method.getName()) || "close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("getCatalog".equals(method.getName())) { + return "appdb"; + } + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet resultSet(String[] columns, Object[][] rows) { + int[] index = {-1}; + Object[] lastValue = {null}; + return proxy(ResultSet.class, (method, args) -> { + switch (method.getName()) { + case "next": + index[0] += 1; + return index[0] < rows.length; + case "getString": + Object value = columnValue(columns, rows[index[0]], args[0]); + lastValue[0] = value; + return value == null ? null : String.valueOf(value); + case "getInt": + Object intValue = columnValue(columns, rows[index[0]], args[0]); + lastValue[0] = intValue; + if (intValue == null) { + return 0; + } + if (intValue instanceof Number) { + return ((Number) intValue).intValue(); + } + return Integer.parseInt(String.valueOf(intValue)); + case "wasNull": + return lastValue[0] == null; + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static Object columnValue(String[] columns, Object[] row, Object key) { + if (key instanceof Number) { + return row[((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return row[i]; + } + } + return null; + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) return false; + if (type == Byte.TYPE) return (byte) 0; + if (type == Short.TYPE) return (short) 0; + if (type == Integer.TYPE) return 0; + if (type == Long.TYPE) return 0L; + if (type == Float.TYPE) return 0f; + if (type == Double.TYPE) return 0d; + if (type == Character.TYPE) return (char) 0; + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/goldendb/build.gradle b/agents/drivers/goldendb/build.gradle new file mode 100644 index 0000000..a200788 --- /dev/null +++ b/agents/drivers/goldendb/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.mysql:mysql-connector-j:9.2.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'GoldenDB', 'Main-Class': 'com.dbx.agent.goldendb.GoldendbAgent') + } +} diff --git a/agents/drivers/goldendb/libs/.gitkeep b/agents/drivers/goldendb/libs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java b/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java new file mode 100644 index 0000000..4f7596f --- /dev/null +++ b/agents/drivers/goldendb/src/main/java/com/dbx/agent/goldendb/GoldendbAgent.java @@ -0,0 +1,555 @@ +package com.dbx.agent.goldendb; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Types; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.HashSet; + +public final class GoldendbAgent extends BaseDatabaseAgent { + private Connection connection; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + Class.forName("com.mysql.cj.jdbc.Driver"); + connection = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword()); + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + Class.forName("com.mysql.cj.jdbc.Driver"); + try (Connection conn = DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + "SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String tableType = rs.getString("TABLE_TYPE"); + if ("BASE TABLE".equals(tableType)) { + tableType = "TABLE"; + } + result.add(new TableInfo(rs.getString("TABLE_NAME"), tableType, emptyToNull(rs.getString("TABLE_COMMENT")))); + } + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(schema); + appendInformationSchemaTableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints); + sql.append(" ORDER BY TABLE_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString("TABLE_NAME"), normalizeTableType(rs.getString("TABLE_TYPE")), emptyToNull(rs.getString("TABLE_COMMENT")))); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return queryConstrainedObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE, TABLE_COMMENT AS OBJECT_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(schema); + appendInformationSchemaTableTypePredicate(tableSql, args, constraints); + MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints); + branches.add(tableSql.toString()); + } + if (constraints.objectTypeAllowed("PROCEDURE") || constraints.objectTypeAllowed("FUNCTION")) { + StringBuilder routineSql = new StringBuilder("SELECT ROUTINE_NAME AS OBJECT_NAME, ROUTINE_TYPE AS OBJECT_TYPE, NULL AS OBJECT_COMMENT FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?"); + args.add(schema); + appendRoutineTypePredicate(routineSql, args, constraints); + MetadataSqlSupport.appendNameFilter(routineSql, args, "ROUTINE_NAME", constraints); + branches.add(routineSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE, OBJECT_COMMENT FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'BASE TABLE' THEN 0 WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 WHEN 'FUNCTION' THEN 3 ELSE 9 END, OBJECT_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString("OBJECT_NAME"), normalizeTableType(rs.getString("OBJECT_TYPE")), schema, emptyToNull(rs.getString("OBJECT_COMMENT")))); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String quotedName = JdbcIdentifiers.INSTANCE.backtick(name); + String sql = switch (objectType.toUpperCase(Locale.ROOT)) { + case "VIEW" -> "SHOW CREATE VIEW " + quotedName; + case "PROCEDURE" -> "SHOW CREATE PROCEDURE " + quotedName; + case "FUNCTION" -> "SHOW CREATE FUNCTION " + quotedName; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + + String source = ""; + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + if (rs.next()) { + int sourceIndex = "VIEW".equals(objectType.toUpperCase(Locale.ROOT)) ? 2 : 3; + String value = rs.getString(sourceIndex); + source = value == null ? "" : value; + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Set primaryKeys = new HashSet<>(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + """ + SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + primaryKeys.add(rs.getString("COLUMN_NAME")); + } + } + } + + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + """ + SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, + NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String columnName = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + columnName, + rs.getString("COLUMN_TYPE"), + "YES".equals(rs.getString("IS_NULLABLE")), + rs.getString("COLUMN_DEFAULT"), + primaryKeys.contains(columnName), + rs.getString("EXTRA"), + emptyToNull(rs.getString("COLUMN_COMMENT")), + integerOrNull(rs, "NUMERIC_PRECISION"), + integerOrNull(rs, "NUMERIC_SCALE"), + numberToIntOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + Map> indexMap = new TreeMap<>(); + Map uniqueMap = new HashMap<>(); + Map typeMap = new HashMap<>(); + String quotedTable = JdbcIdentifiers.INSTANCE.backtick(table); + String quotedSchema = JdbcIdentifiers.INSTANCE.backtick(schema); + + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW INDEX FROM " + quotedTable + " FROM " + quotedSchema)) { + while (rs.next()) { + String indexName = rs.getString("Key_name"); + String columnName = rs.getString("Column_name"); + int seqInIndex = rs.getInt("Seq_in_index"); + int nonUnique = rs.getInt("Non_unique"); + String indexType = rs.getString("Index_type"); + + indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>()) + .add(new IndexColumn(seqInIndex, columnName)); + uniqueMap.put(indexName, nonUnique == 0); + typeMap.put(indexName, indexType); + } + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : indexMap.entrySet()) { + List columns = entry.getValue(); + columns.sort((left, right) -> Integer.compare(left.ordinal, right.ordinal)); + List columnNames = new ArrayList<>(); + for (IndexColumn column : columns) { + columnNames.add(column.name); + } + + String indexName = entry.getKey(); + result.add(new IndexInfo( + indexName, + columnNames, + Boolean.TRUE.equals(uniqueMap.get(indexName)), + "PRIMARY".equals(indexName), + null, + typeMap.get(indexName), + null, + null + )); + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + """ + SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY CONSTRAINT_NAME + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("CONSTRAINT_NAME"), + rs.getString("COLUMN_NAME"), + rs.getString("REFERENCED_TABLE_NAME"), + rs.getString("REFERENCED_COLUMN_NAME") + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement( + """ + SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING + FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? + ORDER BY TRIGGER_NAME + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo( + rs.getString("TRIGGER_NAME"), + rs.getString("EVENT_MANIPULATION"), + rs.getString("ACTION_TIMING") + )); + } + } + } + return result; + }); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::resultValue + ); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value; + switch (sqlType) { + case Types.BIGINT: + value = rs.getLong(index); + break; + case Types.INTEGER: + case Types.SMALLINT: + case Types.TINYINT: + value = rs.getInt(index); + break; + case Types.FLOAT: + case Types.REAL: + value = rs.getFloat(index); + break; + case Types.DOUBLE: + value = rs.getDouble(index); + break; + case Types.DECIMAL: + case Types.NUMERIC: + value = rs.getBigDecimal(index); + break; + case Types.BOOLEAN: + case Types.BIT: + value = rs.getBoolean(index); + break; + default: + value = rs.getString(index); + break; + } + return rs.wasNull() ? null : value; + }); + } + + private static String buildUrl(ConnectParams params) { + return "jdbc:mysql://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase() + + "?useSSL=false&allowPublicKeyRetrieval=true"; + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() + || constraints.objectTypeAllowed("PROCEDURE") + || constraints.objectTypeAllowed("FUNCTION"); + } + + private static void appendInformationSchemaTableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND TABLE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static void appendRoutineTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.objectTypeAllowed("PROCEDURE")) { + types.add("PROCEDURE"); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + types.add("FUNCTION"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND ROUTINE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static String normalizeTableType(String tableType) { + return "BASE TABLE".equals(tableType) ? "TABLE" : tableType; + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + private static Integer integerOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Integer ? (Integer) value : null; + } + + private static Integer numberToIntOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static final class IndexColumn { + private final int ordinal; + private final String name; + + private IndexColumn(int ordinal, String name) { + this.ordinal = ordinal; + this.name = name; + } + } + + public static void main(String[] args) { + new JsonRpcServer(new GoldendbAgent()).run(); + } +} diff --git a/agents/drivers/goldendb/src/test/java/com/dbx/agent/goldendb/GoldendbAgentMetadataTest.java b/agents/drivers/goldendb/src/test/java/com/dbx/agent/goldendb/GoldendbAgentMetadataTest.java new file mode 100644 index 0000000..6ec7a08 --- /dev/null +++ b/agents/drivers/goldendb/src/test/java/com/dbx/agent/goldendb/GoldendbAgentMetadataTest.java @@ -0,0 +1,54 @@ +package com.dbx.agent.goldendb; + +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.TestSupport; +import com.dbx.agent.test.JdbcAgentFake; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import java.sql.Connection; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class GoldendbAgentMetadataTest { + @Test + void quotesSchemaAndTableIdentifiersInIndexMetadataSql() { + GoldendbAgent agent = new GoldendbAgent(); + Connection fake = JdbcMetadataSqlFake.connection(); + TestSupport.setPrivateConnection(agent, fake); + + agent.listIndexes("bad`schema", "bad`table"); + + Assertions.assertEquals( + List.of("SHOW INDEX FROM `bad``table` FROM `bad``schema`"), + JdbcMetadataSqlFake.statements + ); + } + + @Test + void constrainedTableMetadataPushesFilterTypesAndPaging() { + GoldendbAgent agent = new GoldendbAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("app", new MetadataListConstraints("ord", 25, 50, List.of("TABLE", "VIEW"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM information_schema.TABLES"), sql); + Assertions.assertTrue(sql.contains("TABLE_TYPE IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("UPPER(TABLE_NAME) LIKE ? ESCAPE '\\\\'"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql); + } + + @Test + void constrainedObjectMetadataPushesRoutineTypesAndPaging() { + GoldendbAgent agent = new GoldendbAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listObjects("app", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM information_schema.ROUTINES"), sql); + Assertions.assertTrue(sql.contains("ROUTINE_TYPE IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("ORDER BY CASE OBJECT_TYPE"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 10"), sql); + } +} diff --git a/agents/drivers/goldendb/src/test/java/com/dbx/agent/goldendb/GoldendbAgentTest.java b/agents/drivers/goldendb/src/test/java/com/dbx/agent/goldendb/GoldendbAgentTest.java new file mode 100644 index 0000000..c22fa1d --- /dev/null +++ b/agents/drivers/goldendb/src/test/java/com/dbx/agent/goldendb/GoldendbAgentTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.goldendb; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; + +class GoldendbAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new GoldendbAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } +} diff --git a/agents/drivers/h2-legacy/build.gradle b/agents/drivers/h2-legacy/build.gradle new file mode 100644 index 0000000..bed8682 --- /dev/null +++ b/agents/drivers/h2-legacy/build.gradle @@ -0,0 +1,13 @@ +sourceSets { + main.java.srcDirs = ['src/main/java', '../h2/src/main/java'] +} + +dependencies { + implementation 'com.h2database:h2:2.1.214' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'H2 2.1 Legacy', 'Main-Class': 'com.dbx.agent.h2legacy.H2LegacyAgent') + } +} diff --git a/agents/drivers/h2-legacy/src/main/java/com/dbx/agent/h2legacy/H2LegacyAgent.java b/agents/drivers/h2-legacy/src/main/java/com/dbx/agent/h2legacy/H2LegacyAgent.java new file mode 100644 index 0000000..18c65dd --- /dev/null +++ b/agents/drivers/h2-legacy/src/main/java/com/dbx/agent/h2legacy/H2LegacyAgent.java @@ -0,0 +1,10 @@ +package com.dbx.agent.h2legacy; + +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.h2.H2Agent; + +public final class H2LegacyAgent extends H2Agent { + public static void main(String[] args) { + new JsonRpcServer(new H2LegacyAgent()).run(); + } +} diff --git a/agents/drivers/h2/build.gradle b/agents/drivers/h2/build.gradle new file mode 100644 index 0000000..070380f --- /dev/null +++ b/agents/drivers/h2/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.h2database:h2:2.3.232' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'H2', 'Main-Class': 'com.dbx.agent.h2.H2Agent') + } +} diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java new file mode 100644 index 0000000..19fd1a1 --- /dev/null +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java @@ -0,0 +1,516 @@ +package com.dbx.agent.h2; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public class H2Agent extends AbstractJdbcAgent { + private String databaseName = ""; + + @Override + protected String driverClass() { + return "org.h2.Driver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) { + databaseName = params.getDatabase(); + } + + @Override + public List listDatabases() { + return List.of(new DatabaseInfo(databaseName.isBlank() ? "default" : databaseName)); + } + + @Override + public List listSchemas() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME" + ); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } + return result; + }); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT TABLE_NAME, TABLE_TYPE + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? + ORDER BY TABLE_NAME + """ + )) { + stmt.setString(1, effectiveSchema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String tableType = rs.getString("TABLE_TYPE"); + if ("BASE TABLE".equals(tableType)) { + tableType = "TABLE"; + } + result.add(new TableInfo(rs.getString("TABLE_NAME"), tableType, null)); + } + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(effectiveSchema); + appendH2TableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints); + sql.append(" ORDER BY TABLE_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (var stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString("TABLE_NAME"), normalizeTableType(rs.getString("TABLE_TYPE")), null)); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + + try (var stmt = requireConnected().prepareStatement( + "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME" + )) { + stmt.setString(1, effectiveSchema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return queryConstrainedObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(effectiveSchema); + appendH2TableTypePredicate(tableSql, args, constraints); + MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints); + branches.add(tableSql.toString()); + } + if (constraints.objectTypeAllowed("PROCEDURE") || constraints.objectTypeAllowed("FUNCTION")) { + StringBuilder routineSql = new StringBuilder("SELECT ROUTINE_NAME AS OBJECT_NAME, ROUTINE_TYPE AS OBJECT_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ?"); + args.add(effectiveSchema); + appendRoutineTypePredicate(routineSql, args, constraints); + MetadataSqlSupport.appendNameFilter(routineSql, args, "ROUTINE_NAME", constraints); + branches.add(routineSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'BASE TABLE' THEN 0 WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 WHEN 'FUNCTION' THEN 3 ELSE 9 END, OBJECT_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (var stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), normalizeTableType(rs.getString(2)), schema, null)); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + String sql = switch (objectType.toUpperCase(Locale.ROOT)) { + case "VIEW" -> "SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"; + case "FUNCTION", "PROCEDURE" -> "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?"; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + + String source = ""; + try (var stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, effectiveSchema); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String value = rs.getString(1); + source = value == null ? "" : value; + } + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + Set primaryKeys = new HashSet<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT ic.COLUMN_NAME + FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic + JOIN INFORMATION_SCHEMA.INDEXES i + ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME + WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ? + AND i.INDEX_TYPE_NAME = 'PRIMARY KEY' + """ + )) { + stmt.setString(1, effectiveSchema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + primaryKeys.add(rs.getString("COLUMN_NAME")); + } + } + } + + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, + NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + """ + )) { + stmt.setString(1, effectiveSchema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String columnName = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + columnName, + rs.getString("DATA_TYPE"), + "YES".equals(rs.getString("IS_NULLABLE")), + rs.getString("COLUMN_DEFAULT"), + primaryKeys.contains(columnName), + null, + null, + intOrNull(rs, "NUMERIC_PRECISION"), + intOrNull(rs, "NUMERIC_SCALE"), + intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + Map> indexMap = new LinkedHashMap<>(); + Map uniqueMap = new HashMap<>(); + Map primaryMap = new HashMap<>(); + Map typeMap = new HashMap<>(); + + try (var stmt = requireConnected().prepareStatement( + """ + SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME + FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic + JOIN INFORMATION_SCHEMA.INDEXES i + ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME + WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ? + ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION + """ + )) { + stmt.setString(1, effectiveSchema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String indexName = rs.getString("INDEX_NAME"); + String columnName = rs.getString("COLUMN_NAME"); + String indexType = rs.getString("INDEX_TYPE_NAME"); + + indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>()).add(columnName); + uniqueMap.put(indexName, rs.getBoolean("IS_UNIQUE")); + primaryMap.put(indexName, "PRIMARY KEY".equals(indexType)); + typeMap.put(indexName, indexType == null ? "" : indexType); + } + } + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : indexMap.entrySet()) { + String name = entry.getKey(); + result.add(new IndexInfo( + name, + entry.getValue(), + uniqueMap.getOrDefault(name, false), + primaryMap.getOrDefault(name, false), + null, + typeMap.get(name), + null, + null + )); + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT FK_NAME, FKCOLUMN_NAME, PKTABLE_NAME, PKCOLUMN_NAME + FROM INFORMATION_SCHEMA.CROSS_REFERENCES + WHERE FKTABLE_SCHEMA = ? AND FKTABLE_NAME = ? + ORDER BY FK_NAME + """ + )) { + stmt.setString(1, effectiveSchema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("FK_NAME"), + rs.getString("FKCOLUMN_NAME"), + rs.getString("PKTABLE_NAME"), + rs.getString("PKCOLUMN_NAME") + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + String effectiveSchema = resolveSchema(schema); + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING + FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? + ORDER BY TRIGGER_NAME + """ + )) { + stmt.setString(1, effectiveSchema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo( + rs.getString("TRIGGER_NAME"), + rs.getString("EVENT_MANIPULATION"), + rs.getString("ACTION_TIMING") + )); + } + } + } + return result; + }); + } + + @Override + public String setSchemaSQL(String schema) { + return "SET SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> JdbcExecutor.INSTANCE.defaultResultValue(rs, index, sqlType)); + } + + @Override + protected JdbcExecutor.ColumnAwareResultValueReader resultValueReader() { + return (rs, index, sqlType, columnTypeName) -> { + if (isJsonType(columnTypeName)) { + byte[] bytes = rs.getBytes(index); + // H2 stores JSON as UTF-8 bytes and getString/default JDBC + // handling exposes a hex literal such as 0x5b5d. Decode only + // JSON columns so real binary data keeps the existing hex view. + return rs.wasNull() || bytes == null ? null : new String(bytes, StandardCharsets.UTF_8); + } + return resultValue(rs, index, sqlType); + }; + } + + static String buildUrl(ConnectParams params) { + String connectionString = params.getConnection_string(); + if (connectionString != null && !connectionString.trim().isEmpty()) { + return connectionString.trim(); + } + if (params.getHost().isBlank()) { + return "jdbc:h2:" + params.getDatabase(); + } + return "jdbc:h2:tcp://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase(); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() + || constraints.objectTypeAllowed("PROCEDURE") + || constraints.objectTypeAllowed("FUNCTION"); + } + + private static void appendH2TableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND TABLE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static void appendRoutineTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.objectTypeAllowed("PROCEDURE")) { + types.add("PROCEDURE"); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + types.add("FUNCTION"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND ROUTINE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static String normalizeTableType(String tableType) { + return "BASE TABLE".equals(tableType) ? "TABLE" : tableType; + } + + private static String resolveSchema(String schema) { + if ("PUBLIC".equalsIgnoreCase(schema) || "INFORMATION_SCHEMA".equalsIgnoreCase(schema)) { + return schema.toUpperCase(Locale.ROOT); + } + return "PUBLIC"; + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static boolean isJsonType(String columnTypeName) { + return columnTypeName != null && "JSON".equals(columnTypeName.trim().toUpperCase(Locale.ROOT)); + } + + public static void main(String[] args) { + new JsonRpcServer(new H2Agent()).run(); + } +} diff --git a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java new file mode 100644 index 0000000..350b168 --- /dev/null +++ b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java @@ -0,0 +1,165 @@ +package com.dbx.agent.h2; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.JdbcExecutionBehaviorTest; +import com.dbx.agent.test.JdbcMetadataBehaviorTest; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class H2AgentMigrationTest { + @Test + void agentExtendsBaseDatabaseAgent() { + Assertions.assertTrue(BaseDatabaseAgent.class.isAssignableFrom(H2Agent.class)); + } + + @Test + void buildUrlUsesExplicitConnectionString() { + ConnectParams params = new ConnectParams( + "127.0.0.1", + 9092, + "test", + "sa", + "", + "", + "jdbc:h2:file:/tmp/dbx-h2-test;AUTO_SERVER=TRUE", + false + ); + + Assertions.assertEquals("jdbc:h2:file:/tmp/dbx-h2-test;AUTO_SERVER=TRUE", H2Agent.buildUrl(params)); + } + + @Test + void buildUrlKeepsTcpModeWhenNoConnectionString() { + ConnectParams params = new ConnectParams("127.0.0.1", 9092, "test", "sa", "", "", "", false); + + Assertions.assertEquals("jdbc:h2:tcp://127.0.0.1:9092/test", H2Agent.buildUrl(params)); + } +} + +class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest { + @Override + protected DatabaseAgent createConnectedAgent(String databaseName) { + return H2AgentTestSupport.createH2Agent(databaseName); + } + + @Override + protected String resultSetSql() { + return "CALL 42"; + } + + @Override + protected List expectedResultSetColumns() { + return List.of("42"); + } + + @Override + protected List> expectedResultSetRows() { + return List.of(List.of(42)); + } + + @Override + protected String rowsSql(int rowCount) { + return "SELECT X FROM SYSTEM_RANGE(1, " + rowCount + ")"; + } + + @Test + void displaysJsonColumnsAsText() { + withAgent("dbx-agent-h2-json", agent -> { + agent.executeQuery( + "CREATE TABLE EVENTS (ID INT PRIMARY KEY, ACTION_PARAMS JSON, RAW_BYTES VARBINARY)", + null, + new ExecuteQueryOptions() + ); + agent.executeQuery( + "INSERT INTO EVENTS VALUES (1, JSON '[]', X'5B5D'), (2, JSON '[{\"type\":\"ACCESS_CONTROL\"}]', X'00FF')", + null, + new ExecuteQueryOptions() + ); + + QueryResult result = agent.executeQuery( + "SELECT ACTION_PARAMS, RAW_BYTES FROM EVENTS ORDER BY ID", + null, + new ExecuteQueryOptions() + ); + + Assertions.assertEquals( + List.of( + List.of("[]", "0x5b5d"), + List.of("[{\"type\":\"ACCESS_CONTROL\"}]", "0x00ff") + ), + result.getRows() + ); + }); + } +} + +class H2MetadataBehaviorTest extends JdbcMetadataBehaviorTest { + @Override + protected DatabaseAgent createConnectedAgent(String databaseName) { + return H2AgentTestSupport.createH2Agent(databaseName); + } + + @Override + protected List metadataFixtureSql() { + return List.of( + "CREATE TABLE BETA_TABLE (ID INT PRIMARY KEY)", + "CREATE TABLE ALPHA_TABLE (ID INT PRIMARY KEY)", + "CREATE TABLE COLUMN_ORDER_SAMPLE (ID INT PRIMARY KEY, NAME VARCHAR(64), CREATED_AT TIMESTAMP)" + ); + } + + @Override + protected String metadataSchema() { + return "PUBLIC"; + } + + @Override + protected List expectedTablesInOrder() { + return List.of("ALPHA_TABLE", "BETA_TABLE", "COLUMN_ORDER_SAMPLE"); + } + + @Override + protected String metadataColumnsTable() { + return "COLUMN_ORDER_SAMPLE"; + } + + @Override + protected List expectedColumnsInOrder() { + return List.of("ID", "NAME", "CREATED_AT"); + } + + @Test + void constrainedTableMetadataFiltersTypesAndPages() { + withAgent("dbx-agent-h2-constrained-metadata", agent -> { + for (String sql : metadataFixtureSql()) { + agent.executeQuery(sql, null, new ExecuteQueryOptions()); + } + + List tables = agent.listTables( + metadataSchema(), + new MetadataListConstraints("table", 1, 1, List.of("TABLE")) + ); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("BETA_TABLE", tables.get(0).getName()); + }); + } +} + +final class H2AgentTestSupport { + private H2AgentTestSupport() { + } + + static DatabaseAgent createH2Agent(String databaseName) { + H2Agent agent = new H2Agent(); + agent.connect(new ConnectParams("", 0, "mem:" + databaseName + ";DB_CLOSE_DELAY=-1", "", "", "", "", false)); + return agent; + } +} diff --git a/agents/drivers/highgo/build.gradle b/agents/drivers/highgo/build.gradle new file mode 100644 index 0000000..1b944ab --- /dev/null +++ b/agents/drivers/highgo/build.gradle @@ -0,0 +1,16 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.highgo:HgdbJdbc:6.2.4' + // PostGIS JDBC is optional: EwkbWktDecoder reflectively probes for + // org.postgis.PGgeometry / net.postgis.jdbc.PGgeometry and falls back to + // its own EWKB parser when the class is absent. compileOnly keeps the + // shadowJar small while still letting users drop the jar into their + // user-driver path if they prefer the upstream parser. + compileOnly 'net.postgis:postgis-jdbc:2024.1.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': '瀚高 HighGo', 'Main-Class': 'com.dbx.agent.highgo.HighgoAgent') + } +} diff --git a/agents/drivers/highgo/src/main/java/com/dbx/agent/highgo/HighgoAgent.java b/agents/drivers/highgo/src/main/java/com/dbx/agent/highgo/HighgoAgent.java new file mode 100644 index 0000000..ee9e6b1 --- /dev/null +++ b/agents/drivers/highgo/src/main/java/com/dbx/agent/highgo/HighgoAgent.java @@ -0,0 +1,20 @@ +package com.dbx.agent.highgo; + +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.PostgresLikeAgent; +import com.dbx.agent.PostgresLikeAgentProfile; + +public final class HighgoAgent extends PostgresLikeAgent { + public static final PostgresLikeAgentProfile HIGHGO_PROFILE = new PostgresLikeAgentProfile( + "com.highgo.jdbc.Driver", + "jdbc:highgo://{host}:{port}/{database}" + ); + + public HighgoAgent() { + super(HIGHGO_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new HighgoAgent()).run(); + } +} diff --git a/agents/drivers/highgo/src/test/java/com/dbx/agent/highgo/HighgoAgentTest.java b/agents/drivers/highgo/src/test/java/com/dbx/agent/highgo/HighgoAgentTest.java new file mode 100644 index 0000000..2186312 --- /dev/null +++ b/agents/drivers/highgo/src/test/java/com/dbx/agent/highgo/HighgoAgentTest.java @@ -0,0 +1,46 @@ +package com.dbx.agent.highgo; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +class HighgoAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new HighgoAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } + + @Test + void declaresHighgoPostgresLikeProfile() { + HighgoAgent agent = new HighgoAgent(); + + Assertions.assertEquals("com.highgo.jdbc.Driver", agent.getProfile().getDriverClass()); + Assertions.assertEquals("jdbc:highgo://{host}:{port}/{database}", agent.getProfile().getUrlTemplate()); + } + + @Test + void readsViewSourceWithQuotedRegclassParameter() { + HighgoAgent agent = new HighgoAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.getObjectSource("bad\"schema", "view's name", "VIEW"); + + Assertions.assertEquals( + Arrays.asList( + "SELECT pg_get_viewdef(to_regclass(?), true)", + "param:1=\"bad\"\"schema\".\"view's name\"" + ), + JdbcMetadataSqlFake.statements + ); + } +} diff --git a/agents/drivers/hive/build.gradle b/agents/drivers/hive/build.gradle new file mode 100644 index 0000000..8787dd7 --- /dev/null +++ b/agents/drivers/hive/build.gradle @@ -0,0 +1,11 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.apache.hive:hive-jdbc:4.0.1:standalone' + implementation 'org.slf4j:slf4j-nop:1.7.30' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Apache Hive', 'Main-Class': 'com.dbx.agent.hive.HiveAgent') + } +} diff --git a/agents/drivers/hive/src/main/java/com/dbx/agent/hive/HiveAgent.java b/agents/drivers/hive/src/main/java/com/dbx/agent/hive/HiveAgent.java new file mode 100644 index 0000000..c4cef21 --- /dev/null +++ b/agents/drivers/hive/src/main/java/com/dbx/agent/hive/HiveAgent.java @@ -0,0 +1,274 @@ +package com.dbx.agent.hive; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public final class HiveAgent extends AbstractJdbcAgent { + + @Override + protected String driverClass() { + return "org.apache.hive.jdbc.HiveDriver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + result.sort(Comparator.comparing(DatabaseInfo::getName)); + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + useSchema(schema); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW TABLES")) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1), "TABLE", null)); + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + return result; + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + try { + return getColumnsFromDescribe(schema, table); + } catch (Exception ignored) { + return getColumnsFromMetadata(requireConnected(), schema, table); + } + }); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + // Hive JDBC fetches rows via Thrift FetchResults in batches controlled + // by fetchSize. A large fetchSize can fail with "Error retrieving next + // row" when the result contains large binary/struct columns. Use a + // small fetchSize (50) so each Thrift batch stays within limits. + return super.executeQuery(sql, schema, new ExecuteQueryOptions( + options.getMaxRows(), + 50, + options.getTimeoutSecs() + )); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + return super.executeQueryPage(sql, schema, withSmallFetchSize(options)); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return super.startTableRead(sql, schema, withSmallFetchSize(options)); + } + + private static QueryPageOptions withSmallFetchSize(QueryPageOptions options) { + return new QueryPageOptions( + options.getPageSize(), 50, options.getMaxRows(), options.getTimeoutSecs() + ); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + private void useSchema(String schema) throws Exception { + try (java.sql.Statement stmt = requireConnected().createStatement()) { + stmt.execute(setSchemaSQL(schema)); + } + } + + private List getColumnsFromDescribe(String schema, String table) throws Exception { + List result = new ArrayList<>(); + useSchema(schema); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("DESCRIBE " + JdbcIdentifiers.INSTANCE.backtick(table))) { + while (rs.next()) { + String colName = trimToNull(rs.getString(1)); + if (colName == null || colName.startsWith("#")) { + continue; + } + result.add(new ColumnInfo( + colName, + trimToEmpty(rs.getString(2)), + true, + null, + false, + null, + optionalComment(rs), + null, + null, + null + )); + } + } + return result; + } + + private static List getColumnsFromMetadata(Connection conn, String schema, String table) throws Exception { + List result = new ArrayList<>(); + DatabaseMetaData meta = conn.getMetaData(); + try (ResultSet rs = meta.getColumns(null, trimToNull(schema), table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + if (trimToNull(name) == null) { + continue; + } + result.add(new ColumnInfo( + name, + trimToEmpty(rs.getString("TYPE_NAME")), + rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + rs.getString("COLUMN_DEF"), + false, + null, + rs.getString("REMARKS"), + intOrNull(rs, "COLUMN_SIZE"), + intOrNull(rs, "DECIMAL_DIGITS"), + characterLength(rs) + )); + } + } + return result; + } + + static String buildUrl(ConnectParams params) { + String connectionString = trimToEmpty(params.getConnection_string()); + if (!connectionString.isEmpty()) { + return connectionString; + } + String url = "jdbc:hive2://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase(); + return appendHiveUrlParams(url, params.getUrl_params()); + } + + private static String appendHiveUrlParams(String url, String urlParams) { + String params = trimHiveUrlParams(urlParams); + if (params.isEmpty()) { + return url; + } + // Hive JDBC authentication options use semicolon-separated URL properties. + return url + (url.endsWith(";") ? "" : ";") + params; + } + + private static String trimHiveUrlParams(String urlParams) { + String value = trimToEmpty(urlParams); + while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) { + value = value.substring(1); + } + return value; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static Integer characterLength(ResultSet rs) throws Exception { + String typeName = rs.getString("TYPE_NAME"); + if (typeName == null) { + return null; + } + String normalized = typeName.toLowerCase(Locale.ROOT); + if (!normalized.contains("char") && !normalized.contains("text") && !normalized.contains("string")) { + return null; + } + return intOrNull(rs, "COLUMN_SIZE"); + } + + private static String optionalComment(ResultSet rs) { + return unchecked(() -> { + try { + String comment = trimToNull(rs.getString(3)); + return comment; + } catch (Exception e) { + return null; + } + }); + } + + public static void main(String[] args) { + new JsonRpcServer(new HiveAgent()).run(); + } +} diff --git a/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentExecutionTest.java b/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentExecutionTest.java new file mode 100644 index 0000000..3e7e2c5 --- /dev/null +++ b/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentExecutionTest.java @@ -0,0 +1,77 @@ +package com.dbx.agent.hive; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryResult; +import com.dbx.agent.test.JdbcAgentFake; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HiveAgentExecutionTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new HiveAgent(); + } + + @Override + protected String resultSetSql() { + return "MSCK REPAIR TABLE sample"; + } + + // Override: HiveAgent.executeQuery forces fetchSize=50 to keep Thrift + // FetchResults batches small (prevents "Error retrieving next row" on + // tables with large binary/struct columns). + @Override + @Test + protected void executesNonSelectStatementsThatReturnResultSets() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions()); + + assertEquals(Collections.singletonList("VALUE"), result.getColumns()); + assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows()); + assertEquals(Arrays.asList("setMaxRows:10001", "setFetchSize:50", "execute"), JdbcAgentFake.calls); + } + + @Override + @Test + protected void appliesExecutionRowAndFetchLimitsToJdbcStatements() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions(1, 25)); + + assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows()); + assertEquals(Arrays.asList("setMaxRows:2", "setFetchSize:50", "execute"), JdbcAgentFake.calls); + } + + @Override + @Test + protected void pagedQueryExecutionPassesCallerFetchSizeToStatement() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + agent.executeQueryPage(resultSetSql(), null, new QueryPageOptions(100, 500, 1000)); + + assertEquals(Arrays.asList("setFetchSize:50", "execute"), JdbcAgentFake.calls); + } + + @Override + @Test + protected void tableReadPassesCallerFetchSizeToStatement() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + agent.startTableRead(resultSetSql(), null, new QueryPageOptions(100, 500, 1000)); + + assertEquals(Arrays.asList("setFetchSize:50", "execute"), JdbcAgentFake.calls); + } +} diff --git a/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentMetadataTest.java b/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentMetadataTest.java new file mode 100644 index 0000000..0ec4fa8 --- /dev/null +++ b/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentMetadataTest.java @@ -0,0 +1,186 @@ +package com.dbx.agent.hive; + +import com.dbx.agent.test.TestSupport; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.ColumnInfo; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.util.List; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class HiveAgentMetadataTest { + @Test + void quotesSchemaAndTableIdentifiersInMetadataSql() { + HiveAgent agent = new HiveAgent(); + Connection fake = JdbcMetadataSqlFake.connection(); + TestSupport.setPrivateConnection(agent, fake); + + agent.listTables("bad`schema"); + agent.getColumns("bad`schema", "bad`table"); + + Assertions.assertEquals( + List.of( + "USE `bad``schema`", + "SHOW TABLES", + "USE `bad``schema`", + "DESCRIBE `bad``table`" + ), + JdbcMetadataSqlFake.statements + ); + } + + @Test + void fallsBackToJdbcMetadataWhenDescribeCannotCreateResultSet() { + HiveAgent agent = new HiveAgent(); + Connection fake = describeFailingMetadataConnection(); + TestSupport.setPrivateConnection(agent, fake); + + List columns = agent.getColumns("default", "events"); + + Assertions.assertEquals(2, columns.size()); + Assertions.assertEquals("id", columns.get(0).getName()); + Assertions.assertEquals("bigint", columns.get(0).getData_type()); + Assertions.assertFalse(columns.get(0).getIs_nullable()); + Assertions.assertEquals("name", columns.get(1).getName()); + Assertions.assertEquals("string", columns.get(1).getData_type()); + Assertions.assertEquals(Integer.valueOf(255), columns.get(1).getCharacter_maximum_length()); + } + + private static Connection describeFailingMetadataConnection() { + DatabaseMetaData meta = proxy(DatabaseMetaData.class, (method, args) -> { + if ("getColumns".equals(method.getName())) { + return rows( + row( + "COLUMN_NAME", "id", + "TYPE_NAME", "bigint", + "NULLABLE", DatabaseMetaData.columnNoNulls, + "COLUMN_DEF", null, + "REMARKS", "identifier", + "COLUMN_SIZE", 19, + "DECIMAL_DIGITS", 0 + ), + row( + "COLUMN_NAME", "name", + "TYPE_NAME", "string", + "NULLABLE", DatabaseMetaData.columnNullable, + "COLUMN_DEF", null, + "REMARKS", null, + "COLUMN_SIZE", 255, + "DECIMAL_DIGITS", 0 + ) + ); + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("createStatement".equals(name)) { + return describeFailingStatement(); + } + if ("getMetaData".equals(name)) { + return meta; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Statement describeFailingStatement() { + return proxy(Statement.class, (method, args) -> { + String name = method.getName(); + if ("execute".equals(name)) { + return false; + } + if ("executeQuery".equals(name)) { + throw new SQLException("Could not create ResultSet: Required field 'type' is unset! Struct:TPrimitiveTypeEntry(type:null)"); + } + return defaultValue(method.getReturnType()); + }); + } + + @SafeVarargs + private static ResultSet rows(Map... rows) { + return proxy(ResultSet.class, new MethodHandler() { + private int index = -1; + + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + index += 1; + return index < rows.length; + } + if ("close".equals(name)) { + return null; + } + Object value = rows[index].get(args[0]); + if ("getString".equals(name)) { + return value == null ? null : String.valueOf(value); + } + if ("getObject".equals(name)) { + return value; + } + if ("getInt".equals(name)) { + return value instanceof Number ? ((Number) value).intValue() : 0; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + row.put(String.valueOf(values[i]), values[i + 1]); + } + return row; + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = (proxy, method, args) -> handler.handle(method, args); + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentTest.java b/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentTest.java new file mode 100644 index 0000000..f3496cd --- /dev/null +++ b/agents/drivers/hive/src/test/java/com/dbx/agent/hive/HiveAgentTest.java @@ -0,0 +1,42 @@ +package com.dbx.agent.hive; + +import com.dbx.agent.ConnectParams; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HiveAgentTest { + @Test + void hiveJdbcStandaloneRuntimeClassesAreAvailable() throws ClassNotFoundException { + Class.forName("org.apache.hive.jdbc.HiveDriver"); + Class.forName("org.apache.hive.org.apache.thrift.protocol.TProtocol"); + } + + @Test + void buildUrlAppendsKerberosUrlParams() { + ConnectParams params = new ConnectParams(); + params.setHost("hive.example.com"); + params.setPort(10000); + params.setDatabase("default"); + params.setUrl_params(";principal=hive/hive.example.com@EXAMPLE.COM;auth=kerberos"); + + assertEquals( + "jdbc:hive2://hive.example.com:10000/default;principal=hive/hive.example.com@EXAMPLE.COM;auth=kerberos", + HiveAgent.buildUrl(params) + ); + } + + @Test + void buildUrlUsesCustomJdbcUrlAsIs() { + ConnectParams params = new ConnectParams(); + params.setConnection_string( + "jdbc:hive2://zk1.example.com,zk2.example.com/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2;principal=hive/_HOST@EXAMPLE.COM" + ); + params.setUrl_params("principal=hive/ignored@EXAMPLE.COM"); + + assertEquals( + "jdbc:hive2://zk1.example.com,zk2.example.com/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2;principal=hive/_HOST@EXAMPLE.COM", + HiveAgent.buildUrl(params) + ); + } +} diff --git a/agents/drivers/informix/build.gradle b/agents/drivers/informix/build.gradle new file mode 100644 index 0000000..1b38423 --- /dev/null +++ b/agents/drivers/informix/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.ibm.informix:jdbc:4.50.10' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'IBM Informix', 'Main-Class': 'com.dbx.agent.informix.InformixAgent') + } +} diff --git a/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java b/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java new file mode 100644 index 0000000..e8a5f5c --- /dev/null +++ b/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java @@ -0,0 +1,556 @@ +package com.dbx.agent.informix; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class InformixAgent extends BaseDatabaseAgent { + private Connection connection; + + @Override + public Connection getConnection() { + return connection; + } + + public static String buildJdbcUrl(ConnectParams params) { + String rawUrlParams = params.getUrl_params(); + String extraParams = rawUrlParams == null ? "" : trimEnd(trimStart(rawUrlParams.trim(), ':', ';'), ';'); + String rawDatabase = params.getDatabase(); + String database = rawDatabase == null || rawDatabase.trim().isEmpty() + ? "sysmaster" : rawDatabase.trim(); + + // Determine INFORMIXSERVER: from dedicated field, url_params, or default + String informixServer = params.getInformix_server(); + informixServer = informixServer == null ? "" : informixServer.trim(); + if (informixServer.isEmpty()) { + informixServer = containsIgnoreCase(extraParams, "INFORMIXSERVER=") ? "" : defaultInformixServer(params.getHost()); + } + String serverParam = informixServer.isEmpty() ? "" : "INFORMIXSERVER=" + informixServer; + + // Add CLIENT_LOCALE and DB_LOCALE defaults if not already specified + if (!containsIgnoreCase(extraParams, "CLIENT_LOCALE=")) { + String localeParam = "CLIENT_LOCALE=en_US.utf8"; + extraParams = extraParams.isEmpty() ? localeParam : extraParams + ";" + localeParam; + } + if (!containsIgnoreCase(extraParams, "DB_LOCALE=")) { + String localeParam = "DB_LOCALE=en_US.utf8"; + extraParams = extraParams.isEmpty() ? localeParam : extraParams + ";" + localeParam; + } + + List jdbcParams = new ArrayList<>(); + if (!serverParam.isEmpty()) { + jdbcParams.add(serverParam); + } + if (!extraParams.isEmpty()) { + jdbcParams.add(extraParams); + } + return "jdbc:informix-sqli://" + params.getHost() + ":" + params.getPort() + "/" + database + ":" + + String.join(";", jdbcParams); + } + + private static String defaultInformixServer(String host) { + return isIpAddress(host) ? "informix" : host; + } + + private static boolean isIpAddress(String host) { + return host.matches("\\d{1,3}(\\.\\d{1,3}){3}") || host.contains(":"); + } + + /** + * Map Informix coltype integer codes to type names. + * See Informix documentation for coltype values. + */ + public static String mapColType(int coltype) { + int baseType = coltype % 256; + return switch (baseType) { + case 0 -> "CHAR"; + case 1 -> "SMALLINT"; + case 2 -> "INTEGER"; + case 3 -> "FLOAT"; + case 4 -> "SMALLFLOAT"; + case 5 -> "DECIMAL"; + case 6 -> "SERIAL"; + case 7 -> "DATE"; + case 8 -> "MONEY"; + case 9 -> "NULL"; + case 10 -> "DATETIME"; + case 11 -> "BYTE"; + case 12 -> "TEXT"; + case 13 -> "VARCHAR"; + case 14 -> "INTERVAL"; + case 15 -> "NCHAR"; + case 16 -> "NVARCHAR"; + case 17 -> "INT8"; + case 18 -> "SERIAL8"; + case 19 -> "SET"; + case 20 -> "MULTISET"; + case 21 -> "LIST"; + case 22 -> "ROW"; + case 23 -> "COLLECTION"; + case 40 -> "LVARCHAR"; + case 41 -> "BOOLEAN"; + case 43, 52 -> "BIGINT"; + case 44, 53 -> "BIGSERIAL"; + default -> "UNKNOWN(" + baseType + ")"; + }; + } + + public static Set primaryKeyColumnNumbers(List parts) { + Set result = new HashSet<>(); + for (Integer part : parts) { + if (part == null) { + continue; + } + int value = Math.abs(part); + if (value > 0) { + result.add(value); + } + } + return result; + } + + public static String databaseCatalogSql() { + return "SELECT name FROM sysmaster:sysdatabases ORDER BY name"; + } + + @Override + public void connect(ConnectParams params) { + String url = buildJdbcUrl(params); + uncheckedVoid(() -> { + Class.forName("com.informix.jdbc.IfxDriver"); + try { + connection = DriverManager.getConnection(url, params.getUsername(), params.getPassword()); + } catch (SQLException e) { + throw new SQLException( + "Informix connection failed.\nURL: " + url.replaceAll("//[^@]+@", "//***@") + "\nError: " + e.getMessage(), + e.getSQLState(), e.getErrorCode() + ); + } + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + String url = buildJdbcUrl(params); + return unchecked(() -> { + Class.forName("com.informix.jdbc.IfxDriver"); + try (Connection conn = DriverManager.getConnection(url, params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(databaseCatalogSql())) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1).trim())); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = """ + SELECT tabname, + CASE tabtype WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE tabtype END + FROM systables + WHERE tabid >= 100 + ORDER BY tabname + """; + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql.stripIndent().trim())) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1).trim(), rs.getString(2).trim(), null)); + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT "); + MetadataSqlSupport.appendLiteralSkipFirst(sql, constraints); + sql.append("tabname, CASE tabtype WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE tabtype END ") + .append("FROM systables WHERE tabid >= 100"); + appendInformixTableTypePredicate(sql, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "tabname", constraints); + sql.append(" ORDER BY tabname"); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1).trim(), rs.getString(2).trim(), null)); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT procname FROM sysprocedures WHERE owner != 'informix' AND isproc = 'f' ORDER BY procname" + )) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1).trim(), "FUNCTION", schema, null)); + } + } + + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT procname FROM sysprocedures WHERE owner != 'informix' AND isproc = 't' ORDER BY procname" + )) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1).trim(), "PROCEDURE", schema, null)); + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return queryConstrainedObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + StringBuilder tableSql = new StringBuilder("SELECT tabname AS object_name, CASE tabtype WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE tabtype END AS object_type, 0 AS object_order FROM systables WHERE tabid >= 100"); + appendInformixTableTypePredicate(tableSql, constraints); + MetadataSqlSupport.appendNameFilter(tableSql, args, "tabname", constraints); + branches.add(tableSql.toString()); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + StringBuilder functionSql = new StringBuilder("SELECT procname AS object_name, 'FUNCTION' AS object_type, 1 AS object_order FROM sysprocedures WHERE owner != 'informix' AND isproc = 'f'"); + MetadataSqlSupport.appendNameFilter(functionSql, args, "procname", constraints); + branches.add(functionSql.toString()); + } + if (constraints.objectTypeAllowed("PROCEDURE")) { + StringBuilder procedureSql = new StringBuilder("SELECT procname AS object_name, 'PROCEDURE' AS object_type, 2 AS object_order FROM sysprocedures WHERE owner != 'informix' AND isproc = 't'"); + MetadataSqlSupport.appendNameFilter(procedureSql, args, "procname", constraints); + branches.add(procedureSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT "); + MetadataSqlSupport.appendLiteralSkipFirst(sql, constraints); + sql.append("object_name, object_type FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY object_order, object_name"); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1).trim(), rs.getString(2).trim(), schema, null)); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String sql = """ + SELECT b.data FROM sysprocbody b + JOIN sysprocedures p ON b.procid = p.procid + WHERE p.procname = ? AND b.datakey = 'T' + ORDER BY b.seqno + """; + StringBuilder sb = new StringBuilder(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.stripIndent().trim())) { + stmt.setString(1, name); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String value = rs.getString(1); + sb.append(value == null ? "" : value); + } + } + } + return new ObjectSource(name, objectType, schema, sb.toString()); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Connection conn = requireConnected(); + Set primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, table); + List result = new ArrayList<>(); + String sql = """ + SELECT c.colname, c.coltype, c.colno + FROM syscolumns c + WHERE c.tabid = (SELECT tabid FROM systables WHERE tabname = ?) + ORDER BY c.colno + """; + try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql.stripIndent().trim())) { + stmt.setString(1, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String colname = rs.getString(1).trim(); + int coltype = rs.getInt(2); + result.add(new ColumnInfo( + colname, + mapColType(coltype), + (coltype & 256) == 0, + null, + primaryKeyColumns.contains(rs.getInt(3)), + null, + null, + null, + null, + null + )); + } + } + } + return result; + }); + } + + private Set getPrimaryKeyColumnNumbers(Connection conn, String table) throws SQLException { + String sql = """ + SELECT i.part1, i.part2, i.part3, i.part4, i.part5, i.part6, i.part7, i.part8, + i.part9, i.part10, i.part11, i.part12, i.part13, i.part14, i.part15, i.part16 + FROM sysconstraints c + JOIN sysindexes i ON i.idxname = c.idxname AND i.tabid = c.tabid + JOIN systables t ON t.tabid = c.tabid + WHERE t.tabname = ? AND c.constrtype = 'P' + """; + + try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql.stripIndent().trim())) { + stmt.setString(1, table); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return Collections.emptySet(); + } + List parts = new ArrayList<>(); + for (int index = 1; index <= 16; index++) { + int value = rs.getInt(index); + parts.add(rs.wasNull() ? null : value); + } + return primaryKeyColumnNumbers(parts); + } + } + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = """ + SELECT t.trigname, t.event, 'TRIGGER' + FROM systriggers t + JOIN systables s ON t.tabid = s.tabid + WHERE s.tabname = ? + """; + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.stripIndent().trim())) { + stmt.setString(1, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo(rs.getString(1).trim(), rs.getString(2).trim(), rs.getString(3).trim())); + } + } + } + return result; + }); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + String normalizedSql = switch (trimEnd(sql.trim(), ';').toUpperCase(Locale.ROOT)) { + case "BEGIN WORK" -> "BEGIN"; + case "COMMIT WORK" -> "COMMIT"; + case "ROLLBACK WORK" -> "ROLLBACK"; + default -> sql; + }; + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + normalizedSql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::stringResultValue + ); + } + + @Override + public String setSchemaSQL(String schema) { + return ""; + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private Object stringResultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() + || constraints.objectTypeAllowed("PROCEDURE") + || constraints.objectTypeAllowed("FUNCTION"); + } + + private static void appendInformixTableTypePredicate(StringBuilder sql, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("'T'"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("'V'"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND tabtype IN (").append(String.join(", ", types)).append(")"); + } + + private static String trimStart(String value, char... chars) { + int index = 0; + while (index < value.length() && contains(chars, value.charAt(index))) { + index++; + } + return value.substring(index); + } + + private static String trimEnd(String value, char... chars) { + int end = value.length(); + while (end > 0 && contains(chars, value.charAt(end - 1))) { + end--; + } + return value.substring(0, end); + } + + private static boolean contains(char[] chars, char value) { + for (char candidate : chars) { + if (candidate == value) { + return true; + } + } + return false; + } + + private static boolean containsIgnoreCase(String value, String needle) { + return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT)); + } + + public static void main(String[] args) { + new JsonRpcServer(new InformixAgent()).run(); + } +} diff --git a/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentExecutionTest.java b/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentExecutionTest.java new file mode 100644 index 0000000..32364b5 --- /dev/null +++ b/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentExecutionTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.informix; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; + +class InformixAgentExecutionTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new InformixAgent(); + } + + @Override + protected String resultSetSql() { + return "EXECUTE PROCEDURE sample_proc()"; + } +} diff --git a/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java b/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java new file mode 100644 index 0000000..ea99fbb --- /dev/null +++ b/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java @@ -0,0 +1,161 @@ +package com.dbx.agent.informix; + +import com.dbx.agent.ConnectParams; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.test.TestSupport; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class InformixAgentTest { + @Test + void buildsJdbcUrlWithExplicitInformixServerAndLocaleParameters() { + String url = InformixAgent.buildJdbcUrl( + new ConnectParams( + "172.26.128.159", + 20013, + "testdb", + "", + "", + "INFORMIXSERVER=informix;CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:informix-sqli://172.26.128.159:20013/testdb:INFORMIXSERVER=informix;CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8", + url + ); + } + + @Test + void fallsBackToHostAsInformixServerWhenNoExplicitServerIsConfigured() { + String url = InformixAgent.buildJdbcUrl( + new ConnectParams( + "informix-host", + 9088, + "sysmaster", + "", + "", + "", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:informix-sqli://informix-host:9088/sysmaster:INFORMIXSERVER=informix-host;CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8", + url + ); + } + + @Test + void fallsBackToInformixServerNameWhenHostIsAnIpAddress() { + String url = InformixAgent.buildJdbcUrl( + new ConnectParams( + "172.26.128.159", + 20013, + "sysmaster", + "", + "", + "", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:informix-sqli://172.26.128.159:20013/sysmaster:INFORMIXSERVER=informix;CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8", + url + ); + } + + @Test + void usesInformixServerFromDedicatedFieldWhenProvided() { + ConnectParams params = new ConnectParams( + "172.26.128.159", + 20013, + "testdb", + "", + "", + "CLIENT_LOCALE=en_US.utf8", + "", + false + ); + params.setInformix_server("ol_informix1410"); + + String url = InformixAgent.buildJdbcUrl(params); + + Assertions.assertEquals( + "jdbc:informix-sqli://172.26.128.159:20013/testdb:INFORMIXSERVER=ol_informix1410;CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8", + url + ); + } + + @Test + void usesSysmasterWhenDatabaseIsBlank() { + String url = InformixAgent.buildJdbcUrl( + new ConnectParams( + "informix-host", + 9088, + "", + "", + "", + "INFORMIXSERVER=informix", + "", + false + ) + ); + + Assertions.assertEquals( + "jdbc:informix-sqli://informix-host:9088/sysmaster:INFORMIXSERVER=informix;CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8", + url + ); + } + + @Test + void extractsPrimaryKeyColumnNumbersFromInformixIndexParts() { + Assertions.assertEquals( + Set.of(1, 3, 5), + InformixAgent.primaryKeyColumnNumbers(Arrays.asList(1, -3, 0, 5, null)) + ); + } + + @Test + void listsDatabasesFromSysmasterCatalog() { + Assertions.assertEquals("SELECT name FROM sysmaster:sysdatabases ORDER BY name", InformixAgent.databaseCatalogSql()); + } + + @Test + void constrainedTableMetadataUsesInformixSkipFirstPushdown() { + InformixAgent agent = new InformixAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("stores", new MetadataListConstraints("ord", 25, 50, List.of("TABLE"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.startsWith("SELECT SKIP 50 FIRST 25 tabname"), sql); + Assertions.assertTrue(sql.contains("tabtype IN ('T')"), sql); + Assertions.assertTrue(sql.contains("UPPER(tabname) LIKE ? ESCAPE '\\\\'"), sql); + Assertions.assertTrue(sql.endsWith("ORDER BY tabname"), sql); + } + + @Test + void constrainedObjectMetadataUsesInformixUnionPushdown() { + InformixAgent agent = new InformixAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listObjects("stores", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.startsWith("SELECT FIRST 10 object_name, object_type FROM ("), sql); + Assertions.assertTrue(sql.contains("FROM sysprocedures"), sql); + Assertions.assertTrue(sql.contains("isproc = 'f'"), sql); + Assertions.assertTrue(sql.contains("isproc = 't'"), sql); + Assertions.assertTrue(sql.endsWith("ORDER BY object_order, object_name"), sql); + } +} diff --git a/agents/drivers/iotdb/build.gradle b/agents/drivers/iotdb/build.gradle new file mode 100644 index 0000000..adaf4bf --- /dev/null +++ b/agents/drivers/iotdb/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation 'org.apache.iotdb:iotdb-jdbc:2.0.8' + implementation 'org.slf4j:slf4j-nop:2.0.16' +} + +tasks.named('jar') { + manifest { + attributes('Agent-Label': 'Apache IoTDB', 'Main-Class': 'com.dbx.agent.iotdb.IoTDBAgent') + } +} diff --git a/agents/drivers/iotdb/src/main/java/com/dbx/agent/iotdb/IoTDBAgent.java b/agents/drivers/iotdb/src/main/java/com/dbx/agent/iotdb/IoTDBAgent.java new file mode 100644 index 0000000..37bd326 --- /dev/null +++ b/agents/drivers/iotdb/src/main/java/com/dbx/agent/iotdb/IoTDBAgent.java @@ -0,0 +1,46 @@ +package com.dbx.agent.iotdb; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +public final class IoTDBAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile IOTDB_PROFILE = new JdbcAgentProfile( + "org.apache.iotdb.jdbc.IoTDBDriver", + "jdbc:iotdb://{host}:{port}/", + 6667, + true + ); + + public IoTDBAgent() { + super(IOTDB_PROFILE); + } + + // IoTDB 的 database 是 `root.xxx` 路径前缀;JDBC metadata 会把同一路径同时投影成 catalog/schema。 + // 这里使用 IoTDB 原生语句作为 database 列表来源,避免依赖驱动的关系型兼容层。 + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (Statement stmt = requireConnection().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + String name = rs.getString(1); + if (name != null && !name.trim().isEmpty()) { + result.add(new DatabaseInfo(name.trim())); + } + } + } + return result; + }); + } + + public static void main(String[] args) { + new JsonRpcServer(new IoTDBAgent()).run(); + } +} diff --git a/agents/drivers/iotdb/src/test/java/com/dbx/agent/iotdb/IoTDBAgentTest.java b/agents/drivers/iotdb/src/test/java/com/dbx/agent/iotdb/IoTDBAgentTest.java new file mode 100644 index 0000000..acfc62f --- /dev/null +++ b/agents/drivers/iotdb/src/test/java/com/dbx/agent/iotdb/IoTDBAgentTest.java @@ -0,0 +1,32 @@ +package com.dbx.agent.iotdb; + +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class IoTDBAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new IoTDBAgent(); + } + + @Override + protected String resultSetSql() { + return "SELECT * FROM sample_table"; + } + + @Test + void buildsOfficialJdbcUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("iotdb.example.com"); + params.setPort(6667); + params.setUrl_params("sql_dialect=table&useSSL=false"); + + Assertions.assertEquals( + "jdbc:iotdb://iotdb.example.com:6667/?sql_dialect=table&useSSL=false", + IoTDBAgent.IOTDB_PROFILE.buildUrl(params) + ); + } +} diff --git a/agents/drivers/iris/build.gradle b/agents/drivers/iris/build.gradle new file mode 100644 index 0000000..0bcd80d --- /dev/null +++ b/agents/drivers/iris/build.gradle @@ -0,0 +1,9 @@ +dependencies { + implementation 'com.intersystems:intersystems-jdbc:3.10.5' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'IRIS', 'Main-Class': 'com.dbx.agent.iris.IrisAgent') + } +} diff --git a/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java b/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java new file mode 100644 index 0000000..380ea62 --- /dev/null +++ b/agents/drivers/iris/src/main/java/com/dbx/agent/iris/IrisAgent.java @@ -0,0 +1,159 @@ +package com.dbx.agent.iris; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.StandardJdbcMetadata; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class IrisAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile IRIS_PROFILE = new JdbcAgentProfile( + "com.intersystems.jdbc.IRISDriver", + "jdbc:IRIS://{host}:{port}/{database}", + 1972, + true + ); + + public IrisAgent() { + super(IRIS_PROFILE); + } + + @Override + public List listSchemas() { + return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), IRIS_PROFILE)); + } + + @Override + public List getColumns(String schema, String table) { + return irisColumns(requireConnection(), schema, table); + } + + static List irisColumns(Connection conn, String schema, String table) { + return unchecked(() -> { + Set primaryKeys = irisPrimaryKeys(conn, schema, table); + List columns = new ArrayList<>(); + try (PreparedStatement stmt = conn.prepareStatement(columnSql(schema))) { + int index = 1; + if (!isBlank(schema)) { + stmt.setString(index++, schema.trim()); + } + stmt.setString(index, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + if (isBlank(name)) { + continue; + } + columns.add(new ColumnInfo( + name, + stringOrDefault(rs, "DATA_TYPE", "Unknown"), + !"NO".equalsIgnoreCase(stringOrNull(rs, "IS_NULLABLE")), + stringOrNull(rs, "COLUMN_DEFAULT"), + primaryKeys.contains(name.toUpperCase(Locale.ROOT)), + null, + null, + intOrNull(rs, "NUMERIC_PRECISION"), + intOrNull(rs, "NUMERIC_SCALE"), + intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") + )); + } + } + } + return columns; + }); + } + + static List dedupeCaseInsensitiveSchemas(List schemas) { + Map byNormalizedName = new LinkedHashMap<>(); + for (String schema : schemas) { + if (schema == null || schema.trim().isEmpty()) { + continue; + } + String name = schema.trim(); + byNormalizedName.putIfAbsent(name.toUpperCase(Locale.ROOT), name); + } + return new ArrayList<>(byNormalizedName.values()); + } + + private static Set irisPrimaryKeys(Connection conn, String schema, String table) { + Set keys = new LinkedHashSet<>(); + try (PreparedStatement stmt = conn.prepareStatement(primaryKeySql(schema))) { + int index = 1; + if (!isBlank(schema)) { + stmt.setString(index++, schema.trim()); + } + stmt.setString(index, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String column = rs.getString("COLUMN_NAME"); + if (!isBlank(column)) { + keys.add(column.toUpperCase(Locale.ROOT)); + } + } + } + } catch (Exception ignored) { + // Primary key metadata is optional for display; column listing must keep working. + } + return keys; + } + + private static String columnSql(String schema) { + StringBuilder sql = new StringBuilder( + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + + "NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH " + + "FROM INFORMATION_SCHEMA.COLUMNS WHERE " + ); + if (!isBlank(schema)) { + sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND "); + } + sql.append("UPPER(TABLE_NAME) = UPPER(?) ORDER BY ORDINAL_POSITION"); + return sql.toString(); + } + + private static String primaryKeySql(String schema) { + StringBuilder sql = new StringBuilder( + "SELECT k.COLUMN_NAME " + + "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE k " + + "WHERE k.CONSTRAINT_TYPE = 'PRIMARY KEY' AND " + ); + if (!isBlank(schema)) { + sql.append("UPPER(k.TABLE_SCHEMA) = UPPER(?) AND "); + } + sql.append("UPPER(k.TABLE_NAME) = UPPER(?) ORDER BY k.ORDINAL_POSITION"); + return sql.toString(); + } + + private static String stringOrDefault(ResultSet rs, String column, String fallback) throws Exception { + String value = stringOrNull(rs, column); + return isBlank(value) ? fallback : value; + } + + private static String stringOrNull(ResultSet rs, String column) throws Exception { + String value = rs.getString(column); + return rs.wasNull() ? null : value; + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + public static void main(String[] args) { + new JsonRpcServer(new IrisAgent()).run(); + } +} diff --git a/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java b/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java new file mode 100644 index 0000000..d2aa319 --- /dev/null +++ b/agents/drivers/iris/src/test/java/com/dbx/agent/iris/IrisAgentTest.java @@ -0,0 +1,154 @@ +package com.dbx.agent.iris; + +import com.dbx.agent.ColumnInfo; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IrisAgentTest { + @Test + void skipsSchemaSwitchingBecauseIrisRejectsSetSchemaContext() { + IrisAgent agent = new IrisAgent(); + + assertEquals("", agent.setSchemaSQL("Ens")); + } + + @Test + void dedupesSchemasCaseInsensitively() { + assertEquals( + Arrays.asList("APP", "SQLUSER", "z_user"), + IrisAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("APP", "SQLUSER", "SQLUser", "app", "z_user")) + ); + } + + @Test + void ignoresBlankSchemasWhenDeduping() { + assertEquals( + Collections.singletonList("SQLUSER"), + IrisAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("", " ", null, "SQLUSER")) + ); + } + + @Test + void readsColumnsWithNativeInformationSchemaQueries() { + List sql = new ArrayList<>(); + Connection conn = fakeConnection(sql); + + List columns = IrisAgent.irisColumns(conn, "SQLUser", "People"); + + assertEquals(2, columns.size()); + assertEquals("ID", columns.get(0).getName()); + assertEquals("INTEGER", columns.get(0).getData_type()); + assertFalse(columns.get(0).getIs_nullable()); + assertTrue(columns.get(0).getIs_primary_key()); + assertEquals("NAME", columns.get(1).getName()); + assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length()); + assertFalse(sql.get(0).contains("LIMIT")); + assertTrue(sql.get(0).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE")); + assertTrue(sql.get(1).contains("INFORMATION_SCHEMA.COLUMNS")); + } + + private static Connection fakeConnection(List sqlLog) { + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String sql = (String) args[0]; + sqlLog.add(sql); + return fakeStatement(sql); + } + if ("getMetaData".equals(method.getName())) { + throw new AssertionError("IRIS columns must not use JDBC DatabaseMetaData.getColumns()"); + } + return defaultValue(method.getReturnType()); + }); + } + + private static PreparedStatement fakeStatement(String sql) { + return proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + if (sql.contains("KEY_COLUMN_USAGE")) { + return fakeResultSet(List.of(Map.of("COLUMN_NAME", "ID"))); + } + return fakeResultSet(List.of( + Map.of( + "COLUMN_NAME", "ID", + "DATA_TYPE", "INTEGER", + "IS_NULLABLE", "NO", + "NUMERIC_PRECISION", 10 + ), + Map.of( + "COLUMN_NAME", "NAME", + "DATA_TYPE", "VARCHAR", + "IS_NULLABLE", "YES", + "CHARACTER_MAXIMUM_LENGTH", 64 + ) + )); + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet fakeResultSet(List> rows) { + final int[] index = {-1}; + final boolean[] wasNull = {false}; + return proxy(ResultSet.class, (method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] < rows.size(); + } + if ("getString".equals(name)) { + Object value = rows.get(index[0]).get((String) args[0]); + wasNull[0] = value == null; + return value == null ? null : String.valueOf(value); + } + if ("getObject".equals(name)) { + Object value = rows.get(index[0]).get((String) args[0]); + wasNull[0] = value == null; + return value; + } + if ("wasNull".equals(name)) { + return wasNull[0]; + } + return defaultValue(method.getReturnType()); + }); + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/kafka/build.gradle b/agents/drivers/kafka/build.gradle new file mode 100644 index 0000000..63f5e38 --- /dev/null +++ b/agents/drivers/kafka/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation 'com.google.code.gson:gson:2.12.1' + implementation 'org.apache.kafka:kafka-clients:3.9.0' + runtimeOnly 'org.slf4j:slf4j-simple:1.7.36' +} + +tasks.named('shadowJar') { + mergeServiceFiles() + manifest { + attributes('Agent-Label': 'Apache Kafka', 'Main-Class': 'com.dbx.agent.kafka.KafkaAgent') + } +} diff --git a/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java b/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java new file mode 100644 index 0000000..ed732e0 --- /dev/null +++ b/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java @@ -0,0 +1,1262 @@ +package com.dbx.agent.kafka; + +import com.google.gson.*; +import org.apache.kafka.clients.admin.*; +import org.apache.kafka.clients.consumer.*; +import org.apache.kafka.clients.producer.*; +import org.apache.kafka.common.*; +import org.apache.kafka.common.acl.*; +import org.apache.kafka.common.config.ConfigResource; +import org.apache.kafka.common.errors.*; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.resource.PatternType; +import org.apache.kafka.common.resource.ResourcePattern; +import org.apache.kafka.common.resource.ResourcePatternFilter; +import org.apache.kafka.common.resource.ResourceType; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Kafka admin agent for DBX. Communicates with the Rust bridge via JSON-RPC + * over stdin/stdout. Uses kafka-clients AdminClient for admin operations and + * KafkaProducer for message production. + */ +public final class KafkaAgent { + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private static final int DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + private static final int DEFAULT_SESSION_TIMEOUT_MS = 30_000; + private static final Set KERBEROS_SYSTEM_PROPERTY_KEYS = Set.of( + "java.security.krb5.conf", + "sun.security.krb5.debug", + "javax.security.auth.useSubjectCredsOnly" + ); + private static final Map BASELINE_KERBEROS_SYSTEM_PROPERTIES = + snapshotKerberosSystemProperties(); + + private static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( + "mq_connect", "mq_test_connection", "mq_topics", "mq_consumer_groups", + "mq_messages", "mq_acl", "mq_config", "mq_monitoring" + )); + + private static AdminClient adminClient; + private static KafkaProducer producer; + private static volatile boolean shutdownRequested; + + private KafkaAgent() {} + + // ----------------------------------------------------------------------- + // Entry point + // ----------------------------------------------------------------------- + + public static void main(String[] args) throws Exception { + System.setProperty("org.slf4j.simpleLogger.logFile", "System.err"); + System.out.println("{\"ready\":true}"); + System.out.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + while (true) { + String line = reader.readLine(); + if (line == null) break; + String response = handleRequest(line); + System.out.println(response); + System.out.flush(); + if (shutdownRequested) { + System.exit(0); + } + } + } + + // ----------------------------------------------------------------------- + // JSON-RPC dispatch + // ----------------------------------------------------------------------- + + static String handleRequest(String line) { + JsonObject req = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = req.get("id"); + String method = req.get("method").getAsString(); + JsonObject params = req.has("params") && req.get("params").isJsonObject() + ? req.getAsJsonObject("params") : new JsonObject(); + + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + response.add("id", id); + + try { + Object result = dispatch(method, params); + response.add("result", GSON.toJsonTree(result)); + } catch (Exception e) { + JsonObject error = new JsonObject(); + error.addProperty("code", -1); + error.addProperty("message", normalizeErrorMessage(e)); + response.add("error", error); + } + return GSON.toJson(response); + } + + private static Object dispatch(String method, JsonObject params) throws Exception { + return switch (method) { + case "handshake" -> handshakeResult(); + case "connect" -> connect(params); + case "test_connection" -> testConnection(params); + case "disconnect" -> { closeClients(); yield Collections.singletonMap("ok", true); } + case "shutdown" -> { closeClients(); shutdownRequested = true; yield Collections.singletonMap("ok", true); } + // Topic management + case "mq_list_topics" -> listTopics(params); + case "mq_create_topic" -> createTopic(params); + case "mq_delete_topic" -> deleteTopic(params); + case "mq_update_partitions" -> updatePartitions(params); + case "mq_get_topic_stats" -> getTopicStats(params); + case "mq_get_topic_config" -> getTopicConfig(params); + case "mq_alter_topic_config" -> alterTopicConfig(params); + // Consumer groups + case "mq_list_consumer_groups" -> listConsumerGroups(params); + case "mq_describe_consumer_group" -> describeConsumerGroup(params); + case "mq_delete_consumer_group" -> deleteConsumerGroup(params); + case "mq_reset_consumer_group_offsets" -> resetConsumerGroupOffsets(params); + case "mq_list_producers" -> listProducers(params); + // Messages + case "mq_peek_messages" -> peekMessages(params); + case "mq_send_message" -> sendMessage(params); + // ACLs + case "mq_list_acls" -> listAcls(params); + case "mq_create_acls" -> createAcls(params); + case "mq_delete_acls" -> deleteAcls(params); + // Cluster / monitoring + case "mq_describe_cluster" -> describeCluster(params); + case "mq_get_consumer_lag" -> getConsumerLag(params); + default -> throw new IllegalArgumentException("Unknown method: " + method); + }; + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + private static Object handshakeResult() { + return new HandshakeResult(1, 1, CAPABILITIES); + } + + private static Object connect(JsonObject params) throws Exception { + JsonObject conn = connectionObject(params); + Map previousKerberosSystemProperties = applyKerberosSystemProperties(conn); + AdminClient nextAdmin = null; + KafkaProducer nextProducer = null; + try { + nextAdmin = buildAdminClient(conn); + // Verify connectivity + nextAdmin.describeCluster().clusterId().get( + intOrDefault(conn, "request_timeout_ms", DEFAULT_REQUEST_TIMEOUT_MS), TimeUnit.MILLISECONDS); + nextProducer = buildProducer(conn); + closeClients(); + applyKerberosSystemProperties(conn); + adminClient = nextAdmin; + producer = nextProducer; + return Collections.singletonMap("ok", true); + } catch (Exception e) { + if (nextAdmin != null) { + nextAdmin.close(Duration.ofSeconds(5)); + } + if (nextProducer != null) { + nextProducer.close(Duration.ofSeconds(5)); + } + restoreKerberosSystemProperties(previousKerberosSystemProperties); + throw e; + } + } + + private static Object testConnection(JsonObject params) throws Exception { + JsonObject conn = connectionObject(params); + Map previousKerberosSystemProperties = applyKerberosSystemProperties(conn); + AdminClient probe = null; + try { + probe = buildAdminClient(conn); + int timeout = intOrDefault(conn, "request_timeout_ms", DEFAULT_REQUEST_TIMEOUT_MS); + DescribeClusterResult cluster = probe.describeCluster(); + String clusterId = cluster.clusterId().get(timeout, TimeUnit.MILLISECONDS); + Node controller = cluster.controller().get(timeout, TimeUnit.MILLISECONDS); + Collection brokers = cluster.nodes().get(timeout, TimeUnit.MILLISECONDS); + + // Probe ACL support: try a describe operation and catch security errors. + boolean aclEnabled = true; + try { + probe.describeAcls(AclBindingFilter.ANY) + .values().get(timeout, TimeUnit.MILLISECONDS); + } catch (Exception aclEx) { + Throwable cause = aclEx; + while (cause != null) { + if (cause.getClass().getSimpleName().contains("SecurityDisabled") + || (cause.getMessage() != null && cause.getMessage().contains("No Authorizer"))) { + aclEnabled = false; + break; + } + cause = cause.getCause(); + } + } + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("clusterId", clusterId); + result.put("controller", controller != null ? nodeToMap(controller) : null); + result.put("aclEnabled", aclEnabled); + List> brokerList = new ArrayList<>(); + for (Node node : brokers) { + brokerList.add(nodeToMap(node)); + } + result.put("brokers", brokerList); + return result; + } finally { + if (probe != null) { + probe.close(Duration.ofSeconds(5)); + } + restoreKerberosSystemProperties(previousKerberosSystemProperties); + } + } + + private static void closeClients() { + if (adminClient != null) { + adminClient.close(Duration.ofSeconds(5)); + adminClient = null; + } + if (producer != null) { + producer.close(Duration.ofSeconds(5)); + producer = null; + } + restoreKerberosSystemProperties(BASELINE_KERBEROS_SYSTEM_PROPERTIES); + } + + // ----------------------------------------------------------------------- + // Client builders + // ----------------------------------------------------------------------- + + static AdminClient buildAdminClient(JsonObject conn) { + Properties props = new Properties(); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers(conn)); + props.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, + intOrDefault(conn, "request_timeout_ms", DEFAULT_REQUEST_TIMEOUT_MS)); + props.put(AdminClientConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, + intOrDefault(conn, "request_timeout_ms", DEFAULT_REQUEST_TIMEOUT_MS)); + applyConnectionProperties(conn, props); + return AdminClient.create(props); + } + + private static KafkaProducer buildProducer(JsonObject conn) { + Properties props = new Properties(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers(conn)); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + "org.apache.kafka.common.serialization.StringSerializer"); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + "org.apache.kafka.common.serialization.ByteArraySerializer"); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + applyConnectionProperties(conn, props); + return new KafkaProducer<>(props); + } + + private static String bootstrapServers(JsonObject conn) { + String servers = stringOrEmpty(conn, "bootstrap_servers"); + if (servers.isBlank()) { + servers = stringOrEmpty(conn, "bootstrapServers"); + } + if (servers.isBlank()) { + throw new IllegalArgumentException("bootstrap_servers is required"); + } + return servers; + } + + static void applySecurityProperties(JsonObject conn, Properties props) { + String securityProtocol = stringOrEmpty(conn, "security_protocol"); + if (securityProtocol.isBlank()) { + securityProtocol = stringOrEmpty(conn, "securityProtocol"); + } + if (securityProtocol.isBlank()) { + securityProtocol = "PLAINTEXT"; + } + props.put("security.protocol", securityProtocol); + + String saslMechanism = stringOrEmpty(conn, "sasl_mechanism"); + if (saslMechanism.isBlank()) { + saslMechanism = stringOrEmpty(conn, "saslMechanism"); + } + if (!saslMechanism.isBlank()) { + props.put("sasl.mechanism", saslMechanism); + } + + String saslUsername = stringOrEmpty(conn, "sasl_username"); + if (saslUsername.isBlank()) saslUsername = stringOrEmpty(conn, "saslUsername"); + String saslPassword = stringOrEmpty(conn, "sasl_password"); + if (saslPassword.isBlank()) saslPassword = stringOrEmpty(conn, "saslPassword"); + + if (!saslUsername.isBlank() && !saslMechanism.isBlank()) { + String jaasTemplate = switch (saslMechanism.toUpperCase()) { + case "PLAIN" -> "org.apache.kafka.common.security.plain.PlainLoginModule required " + + "username=\"%s\" password=\"%s\";"; + case "SCRAM-SHA-256", "SCRAM-SHA-512" -> + "org.apache.kafka.common.security.scram.ScramLoginModule required " + + "username=\"%s\" password=\"%s\";"; + default -> null; + }; + if (jaasTemplate != null) { + props.put("sasl.jaas.config", String.format(jaasTemplate, jaasValue(saslUsername), jaasValue(saslPassword))); + } + } + + JsonObject tls = conn.has("tls") && conn.get("tls").isJsonObject() + ? conn.getAsJsonObject("tls") : null; + boolean skipVerify = boolOrDefault(conn, "tls_skip_verify", false) + || boolOrDefault(conn, "tlsSkipVerify", false) + || (tls != null && boolOrDefault(tls, "skip_verify", false)); + if (skipVerify) { + props.put("ssl.endpoint.identification.algorithm", ""); + } + + // TLS properties + if (tls != null) { + String truststorePath = stringOrEmpty(tls, "truststore_path"); + if (!truststorePath.isBlank()) { + props.put("ssl.truststore.location", truststorePath); + String truststorePassword = stringOrEmpty(tls, "truststore_password"); + if (!truststorePassword.isBlank()) { + props.put("ssl.truststore.password", truststorePassword); + } + } + String keystorePath = stringOrEmpty(tls, "keystore_path"); + if (!keystorePath.isBlank()) { + props.put("ssl.keystore.location", keystorePath); + String keystorePassword = stringOrEmpty(tls, "keystore_password"); + if (!keystorePassword.isBlank()) { + props.put("ssl.keystore.password", keystorePassword); + } + } + } + } + + static void applyConnectionProperties(JsonObject conn, Properties props) { + applySecurityProperties(conn, props); + applyExtraProperties(conn, props); + } + + static String jaasValue(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + @SuppressWarnings("unchecked") + private static void applyExtraProperties(JsonObject conn, Properties props) { + JsonObject properties = conn.has("properties") && conn.get("properties").isJsonObject() + ? conn.getAsJsonObject("properties") : null; + if (properties != null) { + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue().isJsonPrimitive()) { + String key = entry.getKey(); + String value = entry.getValue().getAsString(); + props.put(key, value); + } + } + } + } + + static Map applyKerberosSystemProperties(JsonObject conn) { + Map previous = snapshotKerberosSystemProperties(); + JsonObject properties = connectionProperties(conn); + for (String key : KERBEROS_SYSTEM_PROPERTY_KEYS) { + String value = stringProperty(properties, key); + if (value == null || value.isBlank()) { + value = BASELINE_KERBEROS_SYSTEM_PROPERTIES.get(key); + } + setOrClearSystemProperty(key, value); + } + return previous; + } + + static void restoreKerberosSystemProperties(Map values) { + for (String key : KERBEROS_SYSTEM_PROPERTY_KEYS) { + setOrClearSystemProperty(key, values.get(key)); + } + } + + private static Map snapshotKerberosSystemProperties() { + Map values = new LinkedHashMap<>(); + for (String key : KERBEROS_SYSTEM_PROPERTY_KEYS) { + values.put(key, System.getProperty(key)); + } + return values; + } + + private static JsonObject connectionProperties(JsonObject conn) { + return conn.has("properties") && conn.get("properties").isJsonObject() + ? conn.getAsJsonObject("properties") : null; + } + + private static String stringProperty(JsonObject properties, String key) { + if (properties == null || !properties.has(key) || !properties.get(key).isJsonPrimitive()) { + return null; + } + return properties.get(key).getAsString(); + } + + private static void setOrClearSystemProperty(String key, String value) { + if (value == null || value.isBlank()) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + // ----------------------------------------------------------------------- + // Topic management + // ----------------------------------------------------------------------- + + private static Object listTopics(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + Set names = admin.listTopics(new ListTopicsOptions().timeoutMs(timeout)) + .names().get(timeout, TimeUnit.MILLISECONDS); + if (names.isEmpty()) { + return Collections.singletonMap("topics", Collections.emptyList()); + } + + Map descriptions = admin.describeTopics(names) + .allTopicNames().get(timeout, TimeUnit.MILLISECONDS); + + List> topics = new ArrayList<>(); + for (Map.Entry entry : descriptions.entrySet()) { + TopicDescription desc = entry.getValue(); + Map topic = new LinkedHashMap<>(); + topic.put("name", desc.name()); + topic.put("partitions", desc.partitions().size()); + topic.put("replicationFactor", desc.partitions().isEmpty() ? 0 + : desc.partitions().get(0).replicas().size()); + topic.put("internal", desc.isInternal()); + topics.add(topic); + } + topics.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("topics", topics); + } + + private static Object createTopic(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String name = stringOrEmpty(params, "name"); + int partitions = intOrDefault(params, "partitions", 1); + short replicationFactor = (short) intOrDefault(params, "replicationFactor", 1); + + NewTopic newTopic = new NewTopic(name, partitions, replicationFactor); + + // Optional configs + JsonObject configs = params.has("configs") && params.get("configs").isJsonObject() + ? params.getAsJsonObject("configs") : null; + if (configs != null) { + Map configMap = new HashMap<>(); + for (Map.Entry entry : configs.entrySet()) { + configMap.put(entry.getKey(), entry.getValue().getAsString()); + } + newTopic.configs(configMap); + } + + admin.createTopics(Collections.singletonList(newTopic)) + .all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + private static Object deleteTopic(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String name = stringOrEmpty(params, "name"); + admin.deleteTopics(Collections.singletonList(name)) + .all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + private static Object updatePartitions(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String name = stringOrEmpty(params, "name"); + int totalPartitions = intOrDefault(params, "totalPartitions", 1); + admin.createPartitions(Collections.singletonMap(name, NewPartitions.increaseTo(totalPartitions))) + .all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + private static Object getTopicStats(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String name = stringOrEmpty(params, "name"); + + TopicDescription desc = admin.describeTopics(Collections.singletonList(name)) + .allTopicNames().get(timeout, TimeUnit.MILLISECONDS).get(name); + + // Collect offsets for size estimation + Map endOffsets = new LinkedHashMap<>(); + Map beginOffsets = new LinkedHashMap<>(); + for (TopicPartitionInfo pi : desc.partitions()) { + TopicPartition tp = new TopicPartition(name, pi.partition()); + endOffsets.put(tp, admin.listOffsets(Collections.singletonMap(tp, OffsetSpec.latest())) + .all().get(timeout, TimeUnit.MILLISECONDS).get(tp)); + beginOffsets.put(tp, admin.listOffsets(Collections.singletonMap(tp, OffsetSpec.earliest())) + .all().get(timeout, TimeUnit.MILLISECONDS).get(tp)); + } + + long totalMessages = 0; + List> partitionStats = new ArrayList<>(); + for (TopicPartitionInfo pi : desc.partitions()) { + TopicPartition tp = new TopicPartition(name, pi.partition()); + long end = endOffsets.get(tp).offset(); + long begin = beginOffsets.get(tp).offset(); + long count = end - begin; + totalMessages += count; + + Map ps = new LinkedHashMap<>(); + ps.put("partition", pi.partition()); + ps.put("leader", pi.leader() != null ? pi.leader().id() : -1); + ps.put("replicas", pi.replicas().stream().map(Node::id).collect(Collectors.toList())); + ps.put("isr", pi.isr().stream().map(Node::id).collect(Collectors.toList())); + ps.put("beginOffset", begin); + ps.put("endOffset", end); + ps.put("messageCount", count); + partitionStats.add(ps); + } + + Map result = new LinkedHashMap<>(); + result.put("name", name); + result.put("partitions", desc.partitions().size()); + result.put("replicationFactor", desc.partitions().isEmpty() ? 0 + : desc.partitions().get(0).replicas().size()); + result.put("totalMessages", totalMessages); + result.put("partitionStats", partitionStats); + return result; + } + + private static Object getTopicConfig(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String name = stringOrEmpty(params, "name"); + + ConfigResource resource = new ConfigResource(ConfigResource.Type.TOPIC, name); + Config config = admin.describeConfigs(Collections.singletonList(resource)) + .all().get(timeout, TimeUnit.MILLISECONDS).get(resource); + + Map configs = new LinkedHashMap<>(); + for (ConfigEntry entry : config.entries()) { + Map entryMap = new LinkedHashMap<>(); + entryMap.put("value", entry.value()); + entryMap.put("source", entry.source().name()); + entryMap.put("isSensitive", entry.isSensitive()); + entryMap.put("isReadOnly", entry.isReadOnly()); + entryMap.put("isDefault", entry.isDefault()); + configs.put(entry.name(), entryMap); + } + return Collections.singletonMap("configs", configs); + } + + private static Object alterTopicConfig(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String name = stringOrEmpty(params, "name"); + + JsonArray entries = params.has("configs") && params.get("configs").isJsonArray() + ? params.getAsJsonArray("configs") : new JsonArray(); + + List ops = new ArrayList<>(); + for (JsonElement el : entries) { + JsonObject entry = el.getAsJsonObject(); + String key = entry.get("key").getAsString(); + String value = entry.has("value") && !entry.get("value").isJsonNull() + ? entry.get("value").getAsString() : null; + String opStr = stringOrDefault(entry, "op", "set"); + AlterConfigOp.OpType opType = switch (opStr.toLowerCase()) { + case "delete" -> AlterConfigOp.OpType.DELETE; + case "append" -> AlterConfigOp.OpType.APPEND; + case "subtract" -> AlterConfigOp.OpType.SUBTRACT; + default -> AlterConfigOp.OpType.SET; + }; + ops.add(new AlterConfigOp(new ConfigEntry(key, value), opType)); + } + + ConfigResource resource = new ConfigResource(ConfigResource.Type.TOPIC, name); + admin.incrementalAlterConfigs(Collections.singletonMap(resource, ops)) + .all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + // ----------------------------------------------------------------------- + // Consumer groups + // ----------------------------------------------------------------------- + + private static Object listConsumerGroups(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + Collection groups = admin.listConsumerGroups( + new ListConsumerGroupsOptions().timeoutMs(timeout)) + .all().get(timeout, TimeUnit.MILLISECONDS); + + List> result = new ArrayList<>(); + for (ConsumerGroupListing group : groups) { + Map g = new LinkedHashMap<>(); + g.put("groupId", group.groupId()); + g.put("state", group.state().map(Enum::name).orElse("UNKNOWN")); + g.put("simpleGroup", group.isSimpleConsumerGroup()); + result.add(g); + } + result.sort(Comparator.comparing(m -> (String) m.get("groupId"))); + return Collections.singletonMap("groups", result); + } + + private static Object describeConsumerGroup(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String groupId = stringOrEmpty(params, "groupId"); + + ConsumerGroupDescription desc = admin.describeConsumerGroups(Collections.singletonList(groupId)) + .all().get(timeout, TimeUnit.MILLISECONDS).get(groupId); + + Map result = new LinkedHashMap<>(); + result.put("groupId", desc.groupId()); + result.put("state", desc.state().name()); + result.put("coordinator", nodeToMap(desc.coordinator())); + result.put("partitionAssignor", desc.partitionAssignor()); + + List> members = new ArrayList<>(); + for (MemberDescription member : desc.members()) { + Map m = new LinkedHashMap<>(); + m.put("memberId", member.consumerId()); + m.put("clientId", member.clientId()); + m.put("host", member.host()); + List> assignments = new ArrayList<>(); + for (TopicPartition tp : member.assignment().topicPartitions()) { + Map a = new LinkedHashMap<>(); + a.put("topic", tp.topic()); + a.put("partition", tp.partition()); + assignments.add(a); + } + m.put("assignments", assignments); + members.add(m); + } + result.put("members", members); + return result; + } + + private static Object listProducers(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String topic = stringOrEmpty(params, "topic"); + + try { + TopicDescription desc = admin.describeTopics(Collections.singletonList(topic)) + .allTopicNames().get(timeout, TimeUnit.MILLISECONDS).get(topic); + List partitions = desc.partitions().stream() + .map(pi -> new TopicPartition(topic, pi.partition())) + .collect(Collectors.toList()); + + DescribeProducersResult described = admin.describeProducers( + partitions, + new DescribeProducersOptions().timeoutMs(timeout)); + + Map> byProducer = new LinkedHashMap<>(); + for (TopicPartition tp : partitions) { + DescribeProducersResult.PartitionProducerState state = + described.partitionResult(tp).get(timeout, TimeUnit.MILLISECONDS); + for (ProducerState producerState : state.activeProducers()) { + long producerId = producerState.producerId(); + Map producer = byProducer.computeIfAbsent(producerId, id -> { + Map p = new LinkedHashMap<>(); + p.put("producerId", id); + p.put("producerName", "producer-" + id); + p.put("msgRateIn", 0.0); + p.put("msgThroughputIn", 0.0); + p.put("clientVersion", "Kafka producer"); + p.put("partitions", new ArrayList()); + p.put("lastTimestamp", producerState.lastTimestamp()); + return p; + }); + @SuppressWarnings("unchecked") + List producerPartitions = (List) producer.get("partitions"); + producerPartitions.add(tp.partition()); + long currentLastTimestamp = (long) producer.get("lastTimestamp"); + if (producerState.lastTimestamp() > currentLastTimestamp) { + producer.put("lastTimestamp", producerState.lastTimestamp()); + } + } + } + + for (Map producer : byProducer.values()) { + @SuppressWarnings("unchecked") + List producerPartitions = (List) producer.get("partitions"); + producerPartitions.sort(Integer::compareTo); + producer.put( + "address", + producerPartitions.size() == 1 + ? "partition " + producerPartitions.get(0) + : "partitions " + producerPartitions.stream().map(String::valueOf).collect(Collectors.joining(", ")) + ); + } + + return Collections.singletonMap("producers", new ArrayList<>(byProducer.values())); + } catch (Exception e) { + if (isUnsupportedVersionError(e)) { + return Collections.singletonMap("producers", Collections.emptyList()); + } + throw e; + } + } + + private static Object deleteConsumerGroup(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String groupId = stringOrEmpty(params, "groupId"); + admin.deleteConsumerGroups(Collections.singletonList(groupId)) + .all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + private static Object resetConsumerGroupOffsets(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String groupId = stringOrEmpty(params, "groupId"); + String topic = stringOrEmpty(params, "topic"); + + Map offsets = new HashMap<>(); + JsonArray offsetArray = params.has("offsets") && params.get("offsets").isJsonArray() + ? params.getAsJsonArray("offsets") : new JsonArray(); + + for (JsonElement el : offsetArray) { + JsonObject offsetObj = el.getAsJsonObject(); + int partition = offsetObj.get("partition").getAsInt(); + long offset = offsetObj.get("offset").getAsLong(); + offsets.put(new TopicPartition(topic, partition), new OffsetAndMetadata(offset)); + } + + // If no explicit offsets, check for a "position" parameter. + if (offsets.isEmpty()) { + String position = stringOrDefault(params, "position", "latest"); + Long timestampMs = params.has("timestampMs") && !params.get("timestampMs").isJsonNull() + ? params.get("timestampMs").getAsLong() : null; + TopicDescription desc = admin.describeTopics(Collections.singletonList(topic)) + .allTopicNames().get(timeout, TimeUnit.MILLISECONDS).get(topic); + + Map specMap = new HashMap<>(); + for (TopicPartitionInfo pi : desc.partitions()) { + TopicPartition tp = new TopicPartition(topic, pi.partition()); + specMap.put(tp, offsetSpecForPosition(position, timestampMs)); + } + Map resolved = + admin.listOffsets(specMap).all().get(timeout, TimeUnit.MILLISECONDS); + List unresolvedTimestampPartitions = new ArrayList<>(); + for (Map.Entry entry : resolved.entrySet()) { + long offset = entry.getValue().offset(); + if (offset >= 0) { + offsets.put(entry.getKey(), new OffsetAndMetadata(offset)); + } else { + unresolvedTimestampPartitions.add(entry.getKey()); + } + } + if (!unresolvedTimestampPartitions.isEmpty()) { + Map latestSpecs = new HashMap<>(); + for (TopicPartition tp : unresolvedTimestampPartitions) { + latestSpecs.put(tp, OffsetSpec.latest()); + } + Map latest = + admin.listOffsets(latestSpecs).all().get(timeout, TimeUnit.MILLISECONDS); + for (Map.Entry entry : latest.entrySet()) { + offsets.put(entry.getKey(), new OffsetAndMetadata(entry.getValue().offset())); + } + } + } + + admin.alterConsumerGroupOffsets(groupId, offsets) + .all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + static OffsetSpec offsetSpecForPosition(String position, Long timestampMs) { + String normalized = position == null ? "latest" : position.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "earliest" -> OffsetSpec.earliest(); + case "latest", "" -> OffsetSpec.latest(); + case "timestamp" -> { + if (timestampMs == null) { + throw new IllegalArgumentException("timestampMs is required when position is timestamp"); + } + yield OffsetSpec.forTimestamp(timestampMs); + } + default -> throw new IllegalArgumentException("Unsupported reset position: " + position); + }; + } + + // ----------------------------------------------------------------------- + // Messages + // ----------------------------------------------------------------------- + + private static Object peekMessages(JsonObject params) throws Exception { + String topic = stringOrEmpty(params, "topic"); + int partition = intOrDefault(params, "partition", 0); + long offset = longOrDefault(params, "offset", 0); + int count = intOrDefault(params, "count", 10); + + // Build a temporary consumer for peeking (no commit) + Properties props = new Properties(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + adminClient != null ? adminClient.describeCluster().clusterId() + .get(5, TimeUnit.SECONDS) : "localhost:9092"); + // Reuse the admin's bootstrap servers + JsonObject conn = params.has("connection") && params.get("connection").isJsonObject() + ? params.getAsJsonObject("connection") : null; + if (conn != null) { + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers(conn)); + applyConnectionProperties(conn, props); + } + props.put(ConsumerConfig.GROUP_ID_CONFIG, "dbx-peek-" + UUID.randomUUID()); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + "org.apache.kafka.common.serialization.StringDeserializer"); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + "org.apache.kafka.common.serialization.ByteArrayDeserializer"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "none"); + props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, count); + + TopicPartition tp = new TopicPartition(topic, partition); + try (KafkaConsumer consumer = new KafkaConsumer<>(props)) { + consumer.assign(Collections.singletonList(tp)); + Map beginningOffsets = + consumer.beginningOffsets(Collections.singletonList(tp), Duration.ofSeconds(5)); + Map endOffsets = + consumer.endOffsets(Collections.singletonList(tp), Duration.ofSeconds(5)); + long beginningOffset = beginningOffsets.getOrDefault(tp, 0L); + long endOffset = endOffsets.getOrDefault(tp, beginningOffset); + Long seekOffset = normalizePeekOffset(offset, beginningOffset, endOffset); + if (seekOffset == null) { + return Collections.singletonMap("messages", Collections.emptyList()); + } + consumer.seek(tp, seekOffset); + + List> messages = new ArrayList<>(); + ConsumerRecords records = consumer.poll(Duration.ofSeconds(5)); + for (ConsumerRecord record : records) { + if (messages.size() >= count) break; + Map msg = new LinkedHashMap<>(); + msg.put("topic", record.topic()); + msg.put("partition", record.partition()); + msg.put("offset", record.offset()); + msg.put("timestamp", record.timestamp()); + msg.put("key", record.key()); + // Headers + Map headers = new LinkedHashMap<>(); + record.headers().forEach(h -> + headers.put(h.key(), new String(h.value(), StandardCharsets.UTF_8))); + msg.put("headers", headers); + // Payload + if (record.value() != null) { + msg.put("payloadBase64", Base64.getEncoder().encodeToString(record.value())); + String text = tryDecodeUtf8(record.value()); + if (text != null) { + msg.put("payloadText", text); + } + } else { + msg.put("payloadBase64", ""); + } + messages.add(msg); + } + return Collections.singletonMap("messages", messages); + } + } + + private static Object sendMessage(JsonObject params) throws Exception { + if (producer == null) { + throw new IllegalStateException("Producer is not initialized. Call connect first."); + } + + String topic = stringOrEmpty(params, "topic"); + String key = params.has("key") && !params.get("key").isJsonNull() + ? params.get("key").getAsString() : null; + + // Decode payload from base64 + String payloadBase64 = stringOrEmpty(params, "payloadBase64"); + byte[] value = payloadBase64.isEmpty() ? new byte[0] : Base64.getDecoder().decode(payloadBase64); + + // Build the record + Integer partition = params.has("partition") && !params.get("partition").isJsonNull() + ? params.get("partition").getAsInt() : null; + + ProducerRecord record; + if (partition != null) { + record = new ProducerRecord<>(topic, partition, key, value); + } else { + record = new ProducerRecord<>(topic, key, value); + } + + // Add headers + JsonObject headers = params.has("headers") && params.get("headers").isJsonObject() + ? params.getAsJsonObject("headers") : null; + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + record.headers().add(new RecordHeader( + entry.getKey(), + entry.getValue().getAsString().getBytes(StandardCharsets.UTF_8))); + } + } + + RecordMetadata metadata = producer.send(record).get(30, TimeUnit.SECONDS); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("topic", metadata.topic()); + result.put("partition", metadata.partition()); + result.put("offset", metadata.offset()); + result.put("timestamp", metadata.timestamp()); + return result; + } + + // ----------------------------------------------------------------------- + // ACLs + // ----------------------------------------------------------------------- + + private static Object listAcls(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + + AclBindingFilter filter = buildAclFilter(params); + Collection bindings = admin.describeAcls(filter) + .values().get(timeout, TimeUnit.MILLISECONDS); + + List> acls = new ArrayList<>(); + for (AclBinding binding : bindings) { + Map acl = new LinkedHashMap<>(); + acl.put("resourceType", binding.pattern().resourceType().name()); + acl.put("resourceName", binding.pattern().name()); + acl.put("patternType", binding.pattern().patternType().name()); + acl.put("principal", binding.entry().principal()); + acl.put("host", binding.entry().host()); + acl.put("operation", binding.entry().operation().name()); + acl.put("permissionType", binding.entry().permissionType().name()); + acls.add(acl); + } + return Collections.singletonMap("acls", acls); + } + + private static Object createAcls(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + JsonArray aclsArray = params.has("acls") && params.get("acls").isJsonArray() + ? params.getAsJsonArray("acls") : new JsonArray(); + + List bindings = new ArrayList<>(); + for (JsonElement el : aclsArray) { + JsonObject acl = el.getAsJsonObject(); + ResourceType resourceType = ResourceType.valueOf(stringOrDefault(acl, "resourceType", "TOPIC")); + String resourceName = stringOrEmpty(acl, "resourceName"); + PatternType patternType = PatternType.valueOf(stringOrDefault(acl, "patternType", "LITERAL")); + String principal = stringOrEmpty(acl, "principal"); + String host = stringOrDefault(acl, "host", "*"); + AclOperation operation = AclOperation.valueOf(stringOrDefault(acl, "operation", "ALL")); + AclPermissionType permissionType = AclPermissionType.valueOf( + stringOrDefault(acl, "permissionType", "ALLOW")); + + ResourcePattern pattern = new ResourcePattern(resourceType, resourceName, patternType); + AccessControlEntry entry = new AccessControlEntry(principal, host, operation, permissionType); + bindings.add(new AclBinding(pattern, entry)); + } + + admin.createAcls(bindings).all().get(timeout, TimeUnit.MILLISECONDS); + return Collections.singletonMap("ok", true); + } + + private static Object deleteAcls(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + JsonArray filtersArray = params.has("filters") && params.get("filters").isJsonArray() + ? params.getAsJsonArray("filters") : new JsonArray(); + + List filters = new ArrayList<>(); + for (JsonElement el : filtersArray) { + filters.add(buildAclFilter(el.getAsJsonObject())); + } + if (filters.isEmpty()) { + filters.add(AclBindingFilter.ANY); + } + + Collection deletedBindings = admin.deleteAcls(filters).all().get(timeout, TimeUnit.MILLISECONDS); + int deleted = deletedAclCount(deletedBindings); + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("deleted", deleted); + return result; + } + + static int deletedAclCount(Collection deletedBindings) { + return deletedBindings == null ? 0 : deletedBindings.size(); + } + + private static AclBindingFilter buildAclFilter(JsonObject params) { + ResourceType resourceType = params.has("resourceType") + ? ResourceType.valueOf(params.get("resourceType").getAsString()) : ResourceType.ANY; + String resourceName = params.has("resourceName") && !params.get("resourceName").isJsonNull() + ? params.get("resourceName").getAsString() : null; + PatternType patternType = params.has("patternType") + ? PatternType.valueOf(params.get("patternType").getAsString()) : PatternType.ANY; + + ResourcePatternFilter patternFilter = new ResourcePatternFilter( + resourceType, resourceName, patternType); + + String principal = params.has("principal") && !params.get("principal").isJsonNull() + ? params.get("principal").getAsString() : null; + String host = params.has("host") && !params.get("host").isJsonNull() + ? params.get("host").getAsString() : null; + AclOperation operation = params.has("operation") + ? AclOperation.valueOf(params.get("operation").getAsString()) : AclOperation.ANY; + AclPermissionType permissionType = params.has("permissionType") + ? AclPermissionType.valueOf(params.get("permissionType").getAsString()) : AclPermissionType.ANY; + + AccessControlEntryFilter entryFilter = new AccessControlEntryFilter( + principal, host, operation, permissionType); + return new AclBindingFilter(patternFilter, entryFilter); + } + + // ----------------------------------------------------------------------- + // Cluster / monitoring + // ----------------------------------------------------------------------- + + private static Object describeCluster(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + + DescribeClusterResult cluster = admin.describeCluster(); + String clusterId = cluster.clusterId().get(timeout, TimeUnit.MILLISECONDS); + Node controller = cluster.controller().get(timeout, TimeUnit.MILLISECONDS); + Collection nodes = cluster.nodes().get(timeout, TimeUnit.MILLISECONDS); + + Map result = new LinkedHashMap<>(); + result.put("clusterId", clusterId); + result.put("controller", controller != null ? nodeToMap(controller) : null); + List> brokerList = new ArrayList<>(); + for (Node node : nodes) { + brokerList.add(nodeToMap(node)); + } + result.put("brokers", brokerList); + result.put("nodeCount", nodes.size()); + return result; + } + + private static Object getConsumerLag(JsonObject params) throws Exception { + AdminClient admin = requireAdmin(); + int timeout = requestTimeout(params); + String groupId = stringOrEmpty(params, "groupId"); + String topic = stringOrEmpty(params, "topic"); + + // Get committed offsets for the consumer group + Map committed = admin.listConsumerGroupOffsets(groupId) + .partitionsToOffsetAndMetadata().get(timeout, TimeUnit.MILLISECONDS); + + // Filter to the requested topic + Map topicCommitted = committed.entrySet().stream() + .filter(e -> e.getKey().topic().equals(topic)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + if (topicCommitted.isEmpty()) { + return Collections.singletonMap("partitions", Collections.emptyList()); + } + + // Get end offsets + Map specMap = new HashMap<>(); + for (TopicPartition tp : topicCommitted.keySet()) { + specMap.put(tp, OffsetSpec.latest()); + } + Map endOffsets = + admin.listOffsets(specMap).all().get(timeout, TimeUnit.MILLISECONDS); + + List> partitions = new ArrayList<>(); + long totalLag = 0; + for (Map.Entry entry : topicCommitted.entrySet()) { + TopicPartition tp = entry.getKey(); + long currentOffset = entry.getValue().offset(); + ListOffsetsResult.ListOffsetsResultInfo endInfo = endOffsets.get(tp); + long endOffset = endInfo != null ? endInfo.offset() : -1; + long lag = endOffset >= 0 ? Math.max(0, endOffset - currentOffset) : -1; + totalLag += Math.max(0, lag); + + Map p = new LinkedHashMap<>(); + p.put("partition", tp.partition()); + p.put("currentOffset", currentOffset); + p.put("endOffset", endOffset); + p.put("lag", lag); + partitions.add(p); + } + partitions.sort(Comparator.comparingInt(a -> (int) a.get("partition"))); + + Map result = new LinkedHashMap<>(); + result.put("partitions", partitions); + result.put("totalLag", totalLag); + return result; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static String normalizeErrorMessage(Exception e) { + String message = e.getMessage() == null || e.getMessage().isBlank() + ? e.getClass().getName() + : e.getMessage(); + Throwable root = rootCause(e); + if (root != e && root.getMessage() != null && !root.getMessage().isBlank() + && !message.contains(root.getMessage())) { + message = message + ": " + root.getMessage(); + } + if (isSslHandshakeError(e)) { + message = message + + ". Hint: SSL handshake failed. Check the Kafka security protocol. " + + "Use PLAINTEXT for a PLAINTEXT broker listener, SSL for SSL, " + + "SASL_PLAINTEXT for SASL without TLS, or SASL_SSL for SASL with TLS. " + + "For older Kafka/JDK TLS setups, also check truststore settings, certificates, " + + "hostname verification, and enabled TLS protocol versions."; + } + return message; + } + + private static boolean isSslHandshakeError(Throwable error) { + for (Throwable current : causeChain(error)) { + String className = current.getClass().getName(); + String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase(Locale.ROOT); + if (className.contains("SslAuthenticationException") + || className.contains("SSLHandshakeException") + || message.contains("ssl handshake failed")) { + return true; + } + } + return false; + } + + private static boolean isUnsupportedVersionError(Throwable error) { + for (Throwable current : causeChain(error)) { + String className = current.getClass().getName(); + String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase(Locale.ROOT); + if (className.contains("UnsupportedVersionException") + || message.contains("unsupported version")) { + return true; + } + } + return false; + } + + private static Throwable rootCause(Throwable error) { + Throwable current = null; + for (Throwable cause : causeChain(error)) { + current = cause; + } + return current == null ? error : current; + } + + private static List causeChain(Throwable error) { + List chain = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Throwable current = error; + for (int depth = 0; current != null && depth < 32 && seen.add(current); depth++) { + chain.add(current); + Throwable next = current.getCause(); + if (next == current) { + break; + } + current = next; + } + return chain; + } + + private static AdminClient requireAdmin() { + if (adminClient == null) { + throw new IllegalStateException("Not connected. Call connect first."); + } + return adminClient; + } + + private static JsonObject connectionObject(JsonObject params) { + JsonElement connection = params.get("connection"); + return connection != null && connection.isJsonObject() + ? connection.getAsJsonObject() : params; + } + + private static int requestTimeout(JsonObject params) { + return intOrDefault(params, "timeout_ms", DEFAULT_REQUEST_TIMEOUT_MS); + } + + private static Map nodeToMap(Node node) { + Map m = new LinkedHashMap<>(); + m.put("id", node.id()); + m.put("host", node.host()); + m.put("port", node.port()); + m.put("rack", node.rack()); + return m; + } + + private static String tryDecodeUtf8(byte[] bytes) { + try { + String text = new String(bytes, StandardCharsets.UTF_8); + // Verify round-trip + byte[] reEncoded = text.getBytes(StandardCharsets.UTF_8); + if (Arrays.equals(bytes, reEncoded)) { + return text; + } + } catch (Exception ignored) {} + return null; + } + + static Long normalizePeekOffset(long requestedOffset, long beginningOffset, long endOffset) { + if (endOffset <= beginningOffset) { + return null; + } + if (requestedOffset < beginningOffset) { + return beginningOffset; + } + if (requestedOffset >= endOffset) { + return null; + } + return requestedOffset; + } + + private static String stringOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } + + private static String stringOrEmpty(JsonObject object, String key) { + return stringOrDefault(object, key, ""); + } + + private static String stringOrDefault(JsonObject object, String key, String fallback) { + String value = stringOrNull(object, key); + return value == null ? fallback : value; + } + + private static int intOrDefault(JsonObject object, String key, int fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsInt(); + } + + private static long longOrDefault(JsonObject object, String key, long fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsLong(); + } + + private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsBoolean(); + } + + // ----------------------------------------------------------------------- + // Inner types + // ----------------------------------------------------------------------- + + private static final class HandshakeResult { + private final int protocolVersion; + private final int agentProtocolVersion; + private final List capabilities; + + private HandshakeResult(int protocolVersion, int agentProtocolVersion, List capabilities) { + this.protocolVersion = protocolVersion; + this.agentProtocolVersion = agentProtocolVersion; + this.capabilities = capabilities; + } + } +} diff --git a/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java b/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java new file mode 100644 index 0000000..1b4ad46 --- /dev/null +++ b/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java @@ -0,0 +1,141 @@ +package com.dbx.agent.kafka; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.google.gson.JsonParser; +import java.util.Map; +import java.util.Properties; +import org.junit.jupiter.api.Test; + +class KafkaAgentTest { + @Test + void normalizesPeekOffsetToEarliestAvailableOffset() { + assertEquals(5L, KafkaAgent.normalizePeekOffset(0, 5, 10)); + } + + @Test + void normalizesNegativePeekOffsetToEarliestAvailableOffset() { + assertEquals(0L, KafkaAgent.normalizePeekOffset(-1, 0, 10)); + } + + @Test + void keepsPeekOffsetWhenItIsWithinAvailableRange() { + assertEquals(7L, KafkaAgent.normalizePeekOffset(7, 5, 10)); + } + + @Test + void returnsNoSeekOffsetWhenRequestedOffsetIsAtOrAfterEnd() { + assertNull(KafkaAgent.normalizePeekOffset(10, 5, 10)); + } + + @Test + void returnsNoSeekOffsetWhenTopicHasNoReadableMessages() { + assertNull(KafkaAgent.normalizePeekOffset(0, 5, 5)); + } + + @Test + void appliesKerberosKafkaProperties() { + Properties props = new Properties(); + KafkaAgent.applyConnectionProperties(JsonParser.parseString(""" + { + "security_protocol": "SASL_SSL", + "sasl_mechanism": "GSSAPI", + "properties": { + "sasl.jaas.config": "com.sun.security.auth.module.Krb5LoginModule required useKeyTab=true keyTab=\\"/tmp/user.keytab\\" principal=\\"user@EXAMPLE.COM\\";", + "sasl.kerberos.service.name": "kafka" + } + } + """).getAsJsonObject(), props); + + assertEquals("SASL_SSL", props.getProperty("security.protocol")); + assertEquals("GSSAPI", props.getProperty("sasl.mechanism")); + assertEquals("kafka", props.getProperty("sasl.kerberos.service.name")); + assertEquals( + "com.sun.security.auth.module.Krb5LoginModule required useKeyTab=true keyTab=\"/tmp/user.keytab\" principal=\"user@EXAMPLE.COM\";", + props.getProperty("sasl.jaas.config") + ); + } + + @Test + void appliesAllowedKerberosSystemPropertiesFromConnectionProperties() { + Map previous = KafkaAgent.applyKerberosSystemProperties(JsonParser.parseString(""" + { + "properties": { + "java.security.krb5.conf": "/tmp/krb5.conf", + "sun.security.krb5.debug": "true", + "custom.system.property": "should-not-leak" + } + } + """).getAsJsonObject()); + try { + assertEquals("/tmp/krb5.conf", System.getProperty("java.security.krb5.conf")); + assertEquals("true", System.getProperty("sun.security.krb5.debug")); + assertNull(System.getProperty("custom.system.property")); + } finally { + KafkaAgent.restoreKerberosSystemProperties(previous); + } + } + + @Test + void clearsPreviousKerberosSystemPropertiesForNextConnection() { + String baseline = System.getProperty("java.security.krb5.conf"); + Map previous = KafkaAgent.applyKerberosSystemProperties(JsonParser.parseString(""" + { + "properties": { + "java.security.krb5.conf": "/tmp/cluster-a.krb5.conf" + } + } + """).getAsJsonObject()); + try { + assertEquals("/tmp/cluster-a.krb5.conf", System.getProperty("java.security.krb5.conf")); + + Map beforeSecondConnection = KafkaAgent.applyKerberosSystemProperties(JsonParser.parseString(""" + { + "properties": { + "sasl.kerberos.service.name": "kafka" + } + } + """).getAsJsonObject()); + try { + assertEquals(baseline, System.getProperty("java.security.krb5.conf")); + } finally { + KafkaAgent.restoreKerberosSystemProperties(beforeSecondConnection); + } + } finally { + KafkaAgent.restoreKerberosSystemProperties(previous); + } + } + + @Test + void restoresKerberosSystemPropertiesWhenTestConnectionClientConstructionFails() { + String previous = System.getProperty("java.security.krb5.conf"); + try { + String response = KafkaAgent.handleRequest(""" + { + "jsonrpc": "2.0", + "id": 42, + "method": "test_connection", + "params": { + "connection": { + "bootstrap_servers": "", + "properties": { + "java.security.krb5.conf": "/tmp/leaked-test-connection.krb5.conf" + } + } + } + } + """); + + assertEquals(-1, JsonParser.parseString(response).getAsJsonObject() + .getAsJsonObject("error").get("code").getAsInt()); + assertEquals(previous, System.getProperty("java.security.krb5.conf")); + } finally { + if (previous == null) { + System.clearProperty("java.security.krb5.conf"); + } else { + System.setProperty("java.security.krb5.conf", previous); + } + } + } +} diff --git a/agents/drivers/kingbase/build.gradle b/agents/drivers/kingbase/build.gradle new file mode 100644 index 0000000..7c9eef1 --- /dev/null +++ b/agents/drivers/kingbase/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'cn.com.kingbase:kingbase8:9.0.1.jre7' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': '人大金仓 KingbaseES', 'Main-Class': 'com.dbx.agent.kingbase.KingbaseAgent') + } +} diff --git a/agents/drivers/kingbase/libs/.gitkeep b/agents/drivers/kingbase/libs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java b/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java new file mode 100644 index 0000000..94b0d79 --- /dev/null +++ b/agents/drivers/kingbase/src/main/java/com/dbx/agent/kingbase/KingbaseAgent.java @@ -0,0 +1,839 @@ +package com.dbx.agent.kingbase; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.PostgresLikeAgent; +import com.dbx.agent.PostgresLikeAgentProfile; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class KingbaseAgent extends PostgresLikeAgent { + private static final int KINGBASE_VOID_TYPE_OID = 2278; + private static final String KINGBASE_REL_NAME = "CAST(c.relname AS varchar(256))"; + private static final String KINGBASE_REL_OID = "CAST(c.oid AS varchar(64))"; + private static final String KINGBASE_REL_NAMESPACE = "CAST(c.relnamespace AS varchar(64))"; + private static final String KINGBASE_SCHEMA_NAME = "CAST(n.nspname AS varchar(256))"; + private static final String KINGBASE_NAMESPACE_OID = "CAST(n.oid AS varchar(64))"; + private static final String KINGBASE_DESCRIPTION = "CAST(d.description AS varchar(4000))"; + private static final String KINGBASE_ROUTINE_NAME = "CAST(p.proname AS varchar(256))"; + private static final String KINGBASE_ROUTINE_OID = "CAST(p.oid AS varchar(64))"; + private static final String KINGBASE_ROUTINE_NAMESPACE = "CAST(p.pronamespace AS varchar(64))"; + private static final String KINGBASE_VIEW_NAME = "CAST(v.viewname AS varchar(256))"; + private static final String KINGBASE_VIEW_SCHEMA = "CAST(v.schemaname AS varchar(256))"; + private static final String KINGBASE_MATVIEW_NAME = "CAST(mv.matviewname AS varchar(256))"; + private static final String KINGBASE_MATVIEW_SCHEMA = "CAST(mv.schemaname AS varchar(256))"; + private boolean postgresCatalogMode; + + public static final PostgresLikeAgentProfile KINGBASE_PROFILE = new PostgresLikeAgentProfile( + "com.kingbase8.Driver", + "jdbc:kingbase8://{host}:{port}/{database}" + ); + + public KingbaseAgent() { + super(KINGBASE_PROFILE); + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) { + postgresCatalogMode = false; + if (params.isMysql_compat_mode()) { + setMysqlCompatMode(true); + return; + } + postgresCatalogMode = !catalogExists(connection, "sys_catalog.sys_namespace") + && catalogExists(connection, "pg_catalog.pg_namespace"); + } + + private static boolean catalogExists(Connection connection, String catalog) { + try (Statement stmt = connection.createStatement(); + ResultSet ignored = stmt.executeQuery("SELECT 1 FROM " + catalog + " WHERE 1 = 0")) { + return true; + } catch (Exception ignored) { + // Kingbase compatibility modes expose different catalog families. + return false; + } + } + + @Override + public List listDatabases() { + if (postgresCatalogMode) return super.listDatabases(); + return unchecked(() -> { + if (isMysqlCompatMode()) { + List result = queryDatabases("SELECT current_database() AS database_name"); + if (!result.isEmpty()) return result; + } + for (String sql : List.of( + "SELECT datname AS database_name FROM sys_catalog.sys_database WHERE datistemplate = false ORDER BY datname", + "SELECT datname AS database_name FROM pg_database WHERE datistemplate = false ORDER BY datname" + )) { + try { + List result = queryDatabases(sql); + if (!result.isEmpty()) return result; + } catch (Exception ignored) { + // Kingbase catalog names differ across compatibility modes and versions. + } + } + return Collections.singletonList(new DatabaseInfo(getConfiguredDatabase())); + }); + } + + private List queryDatabases(String sql) throws Exception { + try (PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + List result = new ArrayList<>(); + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString("database_name"))); + } + return result; + } + } + + @Override + public List listSchemas() { + if (postgresCatalogMode) return super.listSchemas(); + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = isMysqlCompatMode() + ? "SELECT schema_name " + + "FROM information_schema.schemata " + + "WHERE UPPER(schema_name) <> 'INFORMATION_SCHEMA' " + + "AND UPPER(schema_name) NOT LIKE 'SYS%' " + + "AND UPPER(schema_name) NOT LIKE 'XLOG%' " + + "ORDER BY schema_name" + : "SELECT nspname AS schema_name " + + "FROM sys_namespace " + + "WHERE nspname NOT LIKE 'sys_temp_%' " + + "AND nspname NOT LIKE 'sys_toast_temp_%' " + + "ORDER BY nspname"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(rs.getString("schema_name")); + } + } + return result; + }); + } + + @Override + public List listTables(String schema) { + if (postgresCatalogMode) return super.listTables(schema); + if (isMysqlCompatMode()) { + return listTables(schema, "table_type IN ('BASE TABLE', 'VIEW')"); + } + return queryRegularTables(schema, MetadataListConstraints.NONE); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + if (postgresCatalogMode) return super.listTables(schema, constraints); + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return isMysqlCompatMode() + ? queryMysqlCompatTables(schema, normalized) + : queryRegularTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryRegularTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + List branches = new ArrayList<>(); + addRegularRelationBranches(branches, args, effectiveSchema(schema), constraints, "table_name", "table_type", "table_comment"); + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT table_name, table_type, table_comment FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_tables ORDER BY table_name"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("table_name"), + normalizeTableType(rs.getString("table_type")), + rs.getString("table_comment") + )); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + private List queryMysqlCompatTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = ?"); + args.add(effectiveSchema(schema)); + appendMysqlCompatTableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "table_name", constraints); + sql.append(" ORDER BY table_name"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1), normalizeTableType(rs.getString(2)))); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + if (postgresCatalogMode) return super.listObjects(schema); + return unchecked(() -> { + String effectiveSchema = effectiveSchema(schema); + List result = new ArrayList<>(); + for (TableInfo table : listTables(effectiveSchema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), effectiveSchema, table.getComment())); + } + if (isMysqlCompatMode()) { + return result; + } + + String sql = "SELECT " + KINGBASE_ROUTINE_NAME + " AS routine_name, " + + "CASE WHEN p.prorettype = " + KINGBASE_VOID_TYPE_OID + " THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type, " + + KINGBASE_DESCRIPTION + " AS routine_comment " + + "FROM sys_catalog.sys_proc p " + + "JOIN sys_catalog.sys_namespace n ON " + KINGBASE_NAMESPACE_OID + " = " + KINGBASE_ROUTINE_NAMESPACE + " " + + "LEFT JOIN sys_catalog.sys_description d ON CAST(d.objoid AS varchar(64)) = " + KINGBASE_ROUTINE_OID + " AND d.objsubid = 0 " + + "WHERE " + KINGBASE_SCHEMA_NAME + " = " + sqlString(effectiveSchema) + " " + + "ORDER BY " + KINGBASE_ROUTINE_NAME; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo( + rs.getString("routine_name"), + rs.getString("routine_type"), + effectiveSchema, + rs.getString("routine_comment") + )); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + if (postgresCatalogMode) return super.listObjects(schema, constraints); + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return isMysqlCompatMode() + ? normalized.filterObjects(toObjects(queryMysqlCompatTables(schema, normalized), effectiveSchema(schema))) + : queryRegularObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryRegularObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + String effectiveSchema = effectiveSchema(schema); + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + addRegularRelationBranches(branches, args, effectiveSchema, constraints, "object_name", "object_type", "object_comment"); + } + if (constraints.objectTypeAllowed("PROCEDURE") || constraints.objectTypeAllowed("FUNCTION")) { + StringBuilder routineSql = new StringBuilder("SELECT ") + .append(KINGBASE_ROUTINE_NAME).append(" AS object_name, ") + .append("CASE WHEN p.prorettype = ").append(KINGBASE_VOID_TYPE_OID).append(" THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, ") + .append(KINGBASE_DESCRIPTION).append(" AS object_comment ") + .append("FROM sys_catalog.sys_proc p ") + .append("JOIN sys_catalog.sys_namespace n ON ").append(KINGBASE_NAMESPACE_OID).append(" = ").append(KINGBASE_ROUTINE_NAMESPACE).append(' ') + .append("LEFT JOIN sys_catalog.sys_description d ON CAST(d.objoid AS varchar(64)) = ").append(KINGBASE_ROUTINE_OID).append(" AND d.objsubid = 0 ") + .append("WHERE ").append(KINGBASE_SCHEMA_NAME).append(" = ").append(sqlString(effectiveSchema)); + appendRoutineKindPredicate(routineSql, args, constraints); + MetadataSqlSupport.appendNameFilter(routineSql, args, KINGBASE_ROUTINE_NAME, constraints); + branches.add(routineSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT object_name, object_type, object_comment FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY CASE object_type WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'MATERIALIZED_VIEW' THEN 2 WHEN 'FOREIGN_TABLE' THEN 3 WHEN 'PROCEDURE' THEN 4 WHEN 'FUNCTION' THEN 5 ELSE 9 END, object_name"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo( + rs.getString("object_name"), + rs.getString("object_type"), + effectiveSchema, + rs.getString("object_comment") + )); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + if (postgresCatalogMode) return super.getObjectSource(schema, name, objectType); + if ("FUNCTION".equalsIgnoreCase(objectType) || "PROCEDURE".equalsIgnoreCase(objectType)) { + return routineSource(schema, name, objectType); + } + if (!"VIEW".equalsIgnoreCase(objectType) && !"MATERIALIZED_VIEW".equalsIgnoreCase(objectType)) { + return new ObjectSource(name, objectType, effectiveSchema(schema), ""); + } + return unchecked(() -> { + String source = ""; + String sql = "SELECT view_definition " + + "FROM information_schema.views " + + "WHERE table_schema = " + sqlString(effectiveSchema(schema)) + + " AND table_name = " + sqlString(name); + if (!isMysqlCompatMode()) { + sql = "SELECT sys_get_viewdef(c.oid) AS view_definition " + + "FROM sys_catalog.sys_class c " + + "JOIN sys_catalog.sys_namespace n ON " + KINGBASE_NAMESPACE_OID + " = " + KINGBASE_REL_NAMESPACE + " " + + "WHERE " + KINGBASE_SCHEMA_NAME + " = " + sqlString(effectiveSchema(schema)) + + " AND " + KINGBASE_REL_NAME + " = " + sqlString(name) + + " LIMIT 1"; + } + try (Statement stmt = requireConnected().createStatement()) { + try (ResultSet rs = stmt.executeQuery(sql)) { + if (rs.next()) { + source = coalesce(rs.getString("view_definition")); + } + } + } + return new ObjectSource(name, objectType, effectiveSchema(schema), source); + }); + } + + private ObjectSource routineSource(String schema, String name, String objectType) { + return unchecked(() -> { + String source = ""; + String sql = "SELECT sys_get_functiondef(p.oid) AS source " + + "FROM sys_catalog.sys_proc p " + + "JOIN sys_catalog.sys_namespace n ON " + KINGBASE_NAMESPACE_OID + " = " + KINGBASE_ROUTINE_NAMESPACE + " " + + "WHERE " + KINGBASE_SCHEMA_NAME + " = " + sqlString(effectiveSchema(schema)) + + " AND " + KINGBASE_ROUTINE_NAME + " = " + sqlString(name) + " " + + "ORDER BY CASE WHEN p.prorettype = " + KINGBASE_VOID_TYPE_OID + " THEN " + + ("PROCEDURE".equalsIgnoreCase(objectType) ? "0 ELSE 1" : "1 ELSE 0") + + " END, p.oid LIMIT 1"; + try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + source = coalesce(rs.getString("source")); + } + } + } + return new ObjectSource(name, objectType, effectiveSchema(schema), source); + }); + } + + @Override + public List getColumns(String schema, String table) { + if (postgresCatalogMode) return super.getColumns(schema, table); + return unchecked(() -> { + Set primaryKeys = primaryKeys(schema, table); + if (!isMysqlCompatMode()) { + return getRegularColumns(schema, table, primaryKeys); + } + return getInformationSchemaColumns(schema, table, primaryKeys); + }); + } + + private List getRegularColumns(String schema, String table, Set primaryKeys) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT a.attname AS column_name, " + + "format_type(a.atttypid, a.atttypmod) AS data_type, " + + "NOT a.attnotnull AS is_nullable, " + + "sys_get_expr(ad.adbin, ad.adrelid) AS column_default, " + + "d.description AS column_comment, " + + "CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 " + + "THEN ((a.atttypmod - 4) >> 16) & 65535 ELSE NULL END AS numeric_precision, " + + "CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 " + + "THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, " + + "CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 " + + "THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length " + + "FROM sys_catalog.sys_attribute a " + + "JOIN sys_catalog.sys_type t ON t.oid = a.atttypid " + + "JOIN sys_catalog.sys_class c ON c.oid = a.attrelid " + + "JOIN sys_catalog.sys_namespace n ON n.oid = c.relnamespace " + + "LEFT JOIN sys_catalog.sys_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum " + + "LEFT JOIN sys_catalog.sys_description d ON d.objoid = a.attrelid AND d.objsubid = a.attnum " + + "WHERE n.nspname = " + sqlString(effectiveSchema(schema)) + + " AND c.relname = " + sqlString(table) + " " + + "AND a.attnum > 0 AND NOT a.attisdropped " + + "ORDER BY a.attnum"; + try (Statement stmt = requireConnected().createStatement()) { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String columnName = rs.getString("column_name"); + result.add(new ColumnInfo( + columnName, + rs.getString("data_type"), + rs.getBoolean("is_nullable"), + rs.getString("column_default"), + primaryKeys.contains(columnName), + null, + rs.getString("column_comment"), + intObject(rs, "numeric_precision"), + intObject(rs, "numeric_scale"), + intObject(rs, "character_maximum_length") + )); + } + } + } + return result; + }); + } + + private List getInformationSchemaColumns(String schema, String table, Set primaryKeys) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT column_name, data_type, is_nullable, column_default, " + + "numeric_precision, numeric_scale, character_maximum_length " + + "FROM information_schema.columns " + + "WHERE table_schema = " + sqlString(effectiveSchema(schema)) + + " AND table_name = " + sqlString(table) + " " + + "ORDER BY ordinal_position"; + try (Statement stmt = requireConnected().createStatement()) { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String columnName = rs.getString("column_name"); + result.add(new ColumnInfo( + columnName, + rs.getString("data_type"), + "YES".equalsIgnoreCase(coalesce(rs.getString("is_nullable"))), + rs.getString("column_default"), + primaryKeys.contains(columnName), + null, + null, + intObject(rs, "numeric_precision"), + intObject(rs, "numeric_scale"), + intObject(rs, "character_maximum_length") + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + if (postgresCatalogMode) return super.listIndexes(schema, table); + return unchecked(() -> { + Map indexes = new LinkedHashMap<>(); + String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " + + "ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " + + "a.attname AS column_name, pos.n AS ordinal_position " + + "FROM SYS_CATALOG.SYS_INDEX ix " + + "JOIN SYS_CATALOG.SYS_CLASS t ON t.oid = ix.indrelid " + + "JOIN SYS_CATALOG.SYS_CLASS i ON i.oid = ix.indexrelid " + + "JOIN SYS_CATALOG.SYS_NAMESPACE n ON n.oid = t.relnamespace " + + "JOIN SYS_CATALOG.SYS_AM am ON am.oid = i.relam " + + "JOIN generate_series(1, 64) AS pos(n) ON pos.n <= array_length(string_to_array(ix.indkey::text, ' '), 1) " + + "JOIN SYS_CATALOG.SYS_ATTRIBUTE a ON a.attrelid = t.oid AND a.attnum = (string_to_array(ix.indkey::text, ' '))[pos.n]::int2 " + + "WHERE n.nspname = " + sqlString(effectiveSchema(schema)) + + " AND t.relname = " + sqlString(table) + " " + + "ORDER BY i.relname, pos.n"; + try (Statement stmt = requireConnected().createStatement()) { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String name = rs.getString("index_name"); + CatalogIndexBuilder builder = indexes.get(name); + if (builder == null) { + builder = new CatalogIndexBuilder( + name, + rs.getBoolean("is_unique"), + rs.getBoolean("is_primary"), + rs.getString("index_type") + ); + indexes.put(name, builder); + } + builder.columns.add(rs.getString("column_name")); + } + } + } + List result = new ArrayList<>(); + for (CatalogIndexBuilder index : indexes.values()) { + result.add(new IndexInfo(index.name, index.columns, index.unique, index.primary, null, index.indexType, null, null)); + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + if (postgresCatalogMode) return super.listForeignKeys(schema, table); + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT fk.constraint_name, fk.column_name, pk.table_name AS ref_table, pk.column_name AS ref_column " + + "FROM information_schema.table_constraints tc " + + "JOIN information_schema.key_column_usage fk " + + "ON fk.constraint_schema = tc.constraint_schema " + + "AND fk.constraint_name = tc.constraint_name " + + "AND fk.table_schema = tc.table_schema " + + "AND fk.table_name = tc.table_name " + + "JOIN information_schema.referential_constraints rc " + + "ON rc.constraint_schema = tc.constraint_schema " + + "AND rc.constraint_name = tc.constraint_name " + + "JOIN information_schema.key_column_usage pk " + + "ON pk.constraint_schema = rc.unique_constraint_schema " + + "AND pk.constraint_name = rc.unique_constraint_name " + + "AND pk.ordinal_position = fk.position_in_unique_constraint " + + "WHERE tc.table_schema = " + sqlString(effectiveSchema(schema)) + + " AND tc.table_name = " + sqlString(table) + " " + + "AND tc.constraint_type = 'FOREIGN KEY' " + + "ORDER BY fk.constraint_name, fk.ordinal_position"; + try (Statement stmt = requireConnected().createStatement()) { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("constraint_name"), + rs.getString("column_name"), + rs.getString("ref_table"), + rs.getString("ref_column") + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + if (postgresCatalogMode) return super.listTriggers(schema, table); + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + if (postgresCatalogMode) return super.setSchemaSQL(schema); + // Kingbase searches sys_catalog implicitly before user schemas unless it + // is listed explicitly. Put it after the selected schema so business + // tables named like system tables (for example sys_config) win. + return "SET search_path TO " + JdbcIdentifiers.INSTANCE.doubleQuote(effectiveSchema(schema)) + ", sys_catalog"; + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType, String columnTypeName) { + if (isTemporalType(sqlType, columnTypeName)) { + return unchecked(() -> { + Object value = rs.getTimestamp(index); + return rs.wasNull() ? null : value.toString(); + }); + } + return super.resultValue(rs, index, sqlType, columnTypeName); + } + + private static boolean isTemporalType(int sqlType, String columnTypeName) { + switch (sqlType) { + case Types.DATE: + case Types.TIME: + case Types.TIME_WITH_TIMEZONE: + case Types.TIMESTAMP: + case Types.TIMESTAMP_WITH_TIMEZONE: + return true; + default: + break; + } + if (columnTypeName == null) { + return false; + } + String normalized = columnTypeName.trim().toLowerCase(Locale.ROOT); + return normalized.equals("date") + || normalized.equals("time") + || normalized.equals("datetime") + || normalized.startsWith("timestamp"); + } + + private Set primaryKeys(String schema, String table) { + return unchecked(() -> { + Set primaryKeys = new LinkedHashSet<>(); + String sql = "SELECT kcu.column_name " + + "FROM information_schema.table_constraints tc " + + "JOIN information_schema.key_column_usage kcu " + + "ON kcu.constraint_schema = tc.constraint_schema " + + "AND kcu.constraint_name = tc.constraint_name " + + "AND kcu.table_schema = tc.table_schema " + + "AND kcu.table_name = tc.table_name " + + "WHERE tc.table_schema = " + sqlString(effectiveSchema(schema)) + + " AND tc.table_name = " + sqlString(table) + " " + + "AND tc.constraint_type = 'PRIMARY KEY' " + + "ORDER BY kcu.ordinal_position"; + try (Statement stmt = requireConnected().createStatement()) { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + primaryKeys.add(rs.getString("column_name")); + } + } + } + return primaryKeys; + }); + } + + private List listTables(String schema, String tableTypePredicate) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT table_name, table_type " + + "FROM information_schema.tables " + + "WHERE table_schema = " + sqlString(effectiveSchema(schema)) + " AND " + tableTypePredicate + " " + + "ORDER BY table_name"; + try (Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1), normalizeTableType(rs.getString(2)))); + } + } + return result; + }); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() + || constraints.objectTypeAllowed("PROCEDURE") + || constraints.objectTypeAllowed("FUNCTION"); + } + + private static void addRegularRelationBranches( + List branches, + List args, + String schema, + MetadataListConstraints constraints, + String nameAlias, + String typeAlias, + String commentAlias + ) { + if (constraints.tableTypeAllowed("TABLE")) { + branches.add(regularRelationBranch(schema, args, constraints, nameAlias, typeAlias, commentAlias, "TABLE")); + } + if (constraints.tableTypeAllowed("VIEW")) { + branches.add(regularViewBranch(schema, args, constraints, nameAlias, typeAlias, commentAlias)); + } + if (constraints.tableTypeAllowed("MATERIALIZED_VIEW")) { + branches.add(regularMaterializedViewBranch(schema, args, constraints, nameAlias, typeAlias, commentAlias)); + } + } + + private static String regularRelationBranch( + String schema, + List args, + MetadataListConstraints constraints, + String nameAlias, + String typeAlias, + String commentAlias, + String objectType + ) { + StringBuilder sql = new StringBuilder("SELECT ") + .append(KINGBASE_REL_NAME).append(" AS ").append(nameAlias).append(", '") + .append(objectType).append("' AS ").append(typeAlias).append(", ") + .append(KINGBASE_DESCRIPTION).append(" AS ").append(commentAlias).append(' ') + .append("FROM sys_catalog.sys_class c ") + .append("JOIN sys_catalog.sys_namespace n ON ").append(KINGBASE_NAMESPACE_OID).append(" = ").append(KINGBASE_REL_NAMESPACE).append(' ') + .append("LEFT JOIN sys_catalog.sys_description d ON CAST(d.objoid AS varchar(64)) = ").append(KINGBASE_REL_OID).append(" AND d.objsubid = 0 ") + .append("WHERE ").append(KINGBASE_SCHEMA_NAME).append(" = ").append(sqlString(schema)); + appendRegularTablePredicate(sql); + MetadataSqlSupport.appendNameFilter(sql, args, KINGBASE_REL_NAME, constraints); + return sql.toString(); + } + + private static String regularViewBranch( + String schema, + List args, + MetadataListConstraints constraints, + String nameAlias, + String typeAlias, + String commentAlias + ) { + // sys_views/sys_matviews avoid relkind while preserving the sidebar's + // separate VIEW and MATERIALIZED_VIEW groups. + StringBuilder sql = new StringBuilder("SELECT ") + .append(KINGBASE_VIEW_NAME).append(" AS ").append(nameAlias).append(", 'VIEW' AS ").append(typeAlias).append(", ") + .append(KINGBASE_DESCRIPTION).append(" AS ").append(commentAlias).append(' ') + .append("FROM sys_catalog.sys_views v ") + .append("JOIN sys_catalog.sys_namespace n ON ").append(KINGBASE_SCHEMA_NAME).append(" = ").append(KINGBASE_VIEW_SCHEMA).append(' ') + .append("JOIN sys_catalog.sys_class c ON ").append(KINGBASE_REL_NAMESPACE).append(" = ").append(KINGBASE_NAMESPACE_OID) + .append(" AND ").append(KINGBASE_REL_NAME).append(" = ").append(KINGBASE_VIEW_NAME).append(' ') + .append("LEFT JOIN sys_catalog.sys_description d ON CAST(d.objoid AS varchar(64)) = ").append(KINGBASE_REL_OID).append(" AND d.objsubid = 0 ") + .append("WHERE ").append(KINGBASE_VIEW_SCHEMA).append(" = ").append(sqlString(schema)); + MetadataSqlSupport.appendNameFilter(sql, args, KINGBASE_VIEW_NAME, constraints); + return sql.toString(); + } + + private static String regularMaterializedViewBranch( + String schema, + List args, + MetadataListConstraints constraints, + String nameAlias, + String typeAlias, + String commentAlias + ) { + StringBuilder sql = new StringBuilder("SELECT ") + .append(KINGBASE_MATVIEW_NAME).append(" AS ").append(nameAlias).append(", 'MATERIALIZED_VIEW' AS ").append(typeAlias).append(", ") + .append(KINGBASE_DESCRIPTION).append(" AS ").append(commentAlias).append(' ') + .append("FROM sys_catalog.sys_matviews mv ") + .append("JOIN sys_catalog.sys_namespace n ON ").append(KINGBASE_SCHEMA_NAME).append(" = ").append(KINGBASE_MATVIEW_SCHEMA).append(' ') + .append("JOIN sys_catalog.sys_class c ON ").append(KINGBASE_REL_NAMESPACE).append(" = ").append(KINGBASE_NAMESPACE_OID) + .append(" AND ").append(KINGBASE_REL_NAME).append(" = ").append(KINGBASE_MATVIEW_NAME).append(' ') + .append("LEFT JOIN sys_catalog.sys_description d ON CAST(d.objoid AS varchar(64)) = ").append(KINGBASE_REL_OID).append(" AND d.objsubid = 0 ") + .append("WHERE ").append(KINGBASE_MATVIEW_SCHEMA).append(" = ").append(sqlString(schema)); + MetadataSqlSupport.appendNameFilter(sql, args, KINGBASE_MATVIEW_NAME, constraints); + return sql.toString(); + } + + private static void appendRegularTablePredicate(StringBuilder sql) { + sql.append(" AND NOT EXISTS (SELECT 1 FROM sys_catalog.sys_rewrite r ") + .append("WHERE CAST(r.ev_class AS varchar(64)) = ").append(KINGBASE_REL_OID) + .append(" AND CAST(r.rulename AS varchar(256)) = '_RETURN')") + .append(" AND NOT EXISTS (SELECT 1 FROM sys_catalog.sys_index ix ") + .append("WHERE CAST(ix.indexrelid AS varchar(64)) = ").append(KINGBASE_REL_OID).append(')') + .append(" AND NOT EXISTS (SELECT 1 FROM sys_catalog.sys_attribute sa1 ") + .append("JOIN sys_catalog.sys_attribute sa2 ON CAST(sa2.attrelid AS varchar(64)) = CAST(sa1.attrelid AS varchar(64)) ") + .append("WHERE CAST(sa1.attrelid AS varchar(64)) = ").append(KINGBASE_REL_OID) + .append(" AND CAST(sa1.attname AS varchar(256)) = 'last_value'") + .append(" AND CAST(sa2.attname AS varchar(256)) = 'log_cnt')"); + } + + private static void appendMysqlCompatTableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + sql.append(" AND table_type IN ('BASE TABLE', 'VIEW')"); + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND table_type IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static void appendRoutineKindPredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + boolean includeProcedures = constraints.objectTypeAllowed("PROCEDURE"); + boolean includeFunctions = constraints.objectTypeAllowed("FUNCTION"); + if (includeProcedures && includeFunctions) { + return; + } + if (!includeProcedures && !includeFunctions) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(includeProcedures + ? " AND p.prorettype = " + KINGBASE_VOID_TYPE_OID + : " AND p.prorettype <> " + KINGBASE_VOID_TYPE_OID); + } + + private static List toObjects(List tables, String schema) { + List result = new ArrayList<>(); + for (TableInfo table : tables) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + return result; + } + + private String effectiveSchema(String schema) { + if (schema != null && !schema.trim().isEmpty()) { + return schema; + } + return "PUBLIC"; + } + + private static Integer intObject(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static String normalizeTableType(String type) { + if (type == null || type.trim().isEmpty()) return "TABLE"; + if ("BASE TABLE".equalsIgnoreCase(type)) return "TABLE"; + return type; + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + private static String sqlString(String value) { + return "'" + coalesce(value).replace("'", "''") + "'"; + } + + private static final class CatalogIndexBuilder { + final String name; + final boolean unique; + final boolean primary; + final String indexType; + final List columns = new ArrayList<>(); + + CatalogIndexBuilder(String name, boolean unique, boolean primary, String indexType) { + this.name = name; + this.unique = unique; + this.primary = primary; + this.indexType = indexType; + } + } + + public static void main(String[] args) { + new JsonRpcServer(new KingbaseAgent()).run(); + } +} diff --git a/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java b/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java new file mode 100644 index 0000000..0e7a55b --- /dev/null +++ b/agents/drivers/kingbase/src/test/java/com/dbx/agent/kingbase/KingbaseAgentTest.java @@ -0,0 +1,603 @@ +package com.dbx.agent.kingbase; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +class KingbaseAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new KingbaseAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } + + @Test + void declaresKingbasePostgresLikeProfile() { + KingbaseAgent agent = new KingbaseAgent(); + + Assertions.assertEquals("com.kingbase8.Driver", agent.getProfile().getDriverClass()); + Assertions.assertEquals("jdbc:kingbase8://{host}:{port}/{database}", agent.getProfile().getUrlTemplate()); + } + + @Test + void schemaSwitchPlacesSysCatalogAfterSelectedSchema() { + KingbaseAgent agent = new KingbaseAgent(); + + Assertions.assertEquals("SET search_path TO \"app\", sys_catalog", agent.setSchemaSQL("app")); + Assertions.assertEquals("SET search_path TO \"app\"\"prod\", sys_catalog", agent.setSchemaSQL("app\"prod")); + } + + @Test + void mysqlCompatListDatabasesUsesCurrentDatabase() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + agent.setMysqlCompatMode(true); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"database_name"}, + new Object[][]{{"TEST"}} + ))); + + Assertions.assertEquals("TEST", agent.listDatabases().get(0).getName()); + Assertions.assertEquals("SELECT current_database() AS database_name", sql.get(0)); + } + + @Test + void regularListDatabasesUsesKingbaseCatalog() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"database_name"}, + new Object[][]{{"app"}, {"analytics"}} + ))); + + List databases = agent.listDatabases(); + Assertions.assertEquals(2, databases.size()); + Assertions.assertEquals("app", databases.get(0).getName()); + Assertions.assertEquals("analytics", databases.get(1).getName()); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_database"), sql.get(0)); + } + + @Test + void regularListDatabasesFallsBackToPostgresCatalog() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnectionWithFailure(sql, "sys_catalog.sys_database", resultSet( + new String[]{"database_name"}, + new Object[][]{{"test"}} + ))); + + List databases = agent.listDatabases(); + + Assertions.assertEquals(1, databases.size()); + Assertions.assertEquals("test", databases.get(0).getName()); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_database"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("FROM pg_database"), sql.get(1)); + } + + @Test + void regularListSchemasKeepsKingbaseSystemSchemas() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"schema_name"}, + new Object[][]{{"public"}, {"sys_catalog"}} + ))); + + Assertions.assertEquals(Arrays.asList("public", "sys_catalog"), agent.listSchemas()); + Assertions.assertTrue(sql.get(0).contains("FROM sys_namespace"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("SYS%"), sql.get(0)); + } + + @Test + void postgresCompatModeUsesPostgresCatalogForMetadata() throws Exception { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + Connection connection = postgresCatalogConnection(sql, resultSet( + new String[]{"schema_name"}, + new Object[][]{{"public"}} + )); + + Method afterConnect = KingbaseAgent.class.getDeclaredMethod("afterConnect", ConnectParams.class, Connection.class); + afterConnect.setAccessible(true); + afterConnect.invoke(agent, new ConnectParams(), connection); + TestSupport.setPrivateConnection(agent, connection); + + Assertions.assertEquals(List.of("public"), agent.listSchemas()); + Assertions.assertEquals("SELECT 1 FROM sys_catalog.sys_namespace WHERE 1 = 0", sql.get(0)); + Assertions.assertEquals("SELECT 1 FROM pg_catalog.pg_namespace WHERE 1 = 0", sql.get(1)); + Assertions.assertTrue(sql.get(2).contains("FROM pg_catalog.pg_namespace"), sql.get(2)); + Assertions.assertEquals("SET search_path TO \"app\"", agent.setSchemaSQL("app")); + } + + @Test + void mysqlCompatListTablesUsesInformationSchema() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + agent.setMysqlCompatMode(true); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"table_name", "table_type"}, + new Object[][]{{"test_timestamps", "BASE TABLE"}} + ))); + + Assertions.assertEquals("test_timestamps", agent.listTables("PUBLIC").get(0).getName()); + Assertions.assertTrue(sql.get(0).contains("FROM information_schema.tables")); + Assertions.assertFalse(sql.get(0).contains("SHOW")); + } + + @Test + void regularListTablesUsesKingbaseCatalogAndIncludesViews() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"table_name", "table_type", "table_comment"}, + new Object[][]{{"app_table", "TABLE", "table comment"}, {"app_view", "VIEW", "view comment"}} + ))); + + List tables = agent.listTables("public"); + + Assertions.assertEquals(2, tables.size()); + Assertions.assertEquals("app_table", tables.get(0).getName()); + Assertions.assertEquals("TABLE", tables.get(0).getTable_type()); + Assertions.assertEquals("app_view", tables.get(1).getName()); + Assertions.assertEquals("VIEW", tables.get(1).getTable_type()); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_class"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_rewrite"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_index"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_views"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_matviews"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("relkind"), sql.get(0)); + } + + @Test + void regularListObjectsIncludesKingbaseViewsProceduresAndFunctions() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"table_name", "table_type", "table_comment"}, + new Object[][]{{"app_table", "TABLE", null}, {"app_view", "VIEW", "view comment"}} + ), + resultSet( + new String[]{"routine_name", "routine_type", "routine_comment"}, + new Object[][]{{"refresh_stats", "PROCEDURE", "proc comment"}, {"format_name", "FUNCTION", "fn comment"}} + ) + )); + + List objects = agent.listObjects("public"); + + Assertions.assertEquals(4, objects.size()); + Assertions.assertEquals("app_table", objects.get(0).getName()); + Assertions.assertEquals("TABLE", objects.get(0).getObject_type()); + Assertions.assertEquals("app_view", objects.get(1).getName()); + Assertions.assertEquals("VIEW", objects.get(1).getObject_type()); + Assertions.assertEquals("refresh_stats", objects.get(2).getName()); + Assertions.assertEquals("PROCEDURE", objects.get(2).getObject_type()); + Assertions.assertEquals("format_name", objects.get(3).getName()); + Assertions.assertEquals("FUNCTION", objects.get(3).getObject_type()); + Assertions.assertTrue(sql.get(1).contains("FROM sys_catalog.sys_proc"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("p.prorettype = 2278"), sql.get(1)); + Assertions.assertFalse(sql.get(1).contains("prokind"), sql.get(1)); + } + + @Test + void constrainedRegularTableMetadataPushesFilterTypesAndPaging() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"table_name", "table_type", "table_comment"}, + new Object[][]{} + ))); + + agent.listTables("public", new MetadataListConstraints("ord", 30, 60, List.of("TABLE", "VIEW"))); + + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_class"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_views"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UNION ALL"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(CAST(c.relname AS varchar(256))) LIKE ? ESCAPE '\\\\'"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("relkind"), sql.get(0)); + Assertions.assertTrue(sql.get(0).endsWith("LIMIT 30 OFFSET 60"), sql.get(0)); + } + + @Test + void constrainedRegularMaterializedViewMetadataUsesMatviewCatalog() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"table_name", "table_type", "table_comment"}, + new Object[][]{{"mv_sales", "MATERIALIZED_VIEW", "cached sales"}} + ))); + + List tables = agent.listTables("public", new MetadataListConstraints("sales", 10, null, List.of("MATERIALIZED_VIEW"))); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("mv_sales", tables.get(0).getName()); + Assertions.assertEquals("MATERIALIZED_VIEW", tables.get(0).getTable_type()); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_matviews"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("FROM sys_catalog.sys_views"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(CAST(mv.matviewname AS varchar(256))) LIKE ? ESCAPE '\\\\'"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("relkind"), sql.get(0)); + } + + @Test + void constrainedRegularObjectMetadataPushesRoutineTypesAndPaging() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"object_name", "object_type", "object_comment"}, + new Object[][]{} + ))); + + agent.listObjects("public", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION"))); + + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_proc"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("p.prorettype = 2278"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("prokind"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("ORDER BY CASE object_type"), sql.get(0)); + Assertions.assertTrue(sql.get(0).endsWith("LIMIT 10"), sql.get(0)); + } + + @Test + void constrainedMysqlCompatTableMetadataPushesInformationSchemaPaging() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + agent.setMysqlCompatMode(true); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"table_name", "table_type"}, + new Object[][]{} + ))); + + agent.listTables("PUBLIC", new MetadataListConstraints("ord", 20, 40, List.of("VIEW"))); + + Assertions.assertTrue(sql.get(0).contains("FROM information_schema.tables"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("table_type IN (?)"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(table_name) LIKE ? ESCAPE '\\\\'"), sql.get(0)); + Assertions.assertTrue(sql.get(0).endsWith("LIMIT 20 OFFSET 40"), sql.get(0)); + } + + @Test + void regularRoutineSourceUsesKingbaseFunctionDefinition() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"source"}, + new Object[][]{{"CREATE FUNCTION public.format_name() RETURNS text AS $$ SELECT 'x'; $$"}} + ))); + + ObjectSource source = agent.getObjectSource("public", "format_name", "FUNCTION"); + + Assertions.assertTrue(source.getSource().startsWith("CREATE FUNCTION public.format_name()"), source.getSource()); + Assertions.assertTrue(sql.get(0).contains("SELECT sys_get_functiondef(p.oid) AS source"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("FROM sys_catalog.sys_proc"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("prokind"), sql.get(0)); + } + + @Test + void regularGetColumnsUsesFormattedCatalogTypes() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"column_name"}, + new Object[][]{{"id"}} + ), + resultSet( + new String[]{ + "column_name", + "data_type", + "is_nullable", + "column_default", + "column_comment", + "numeric_precision", + "numeric_scale", + "character_maximum_length" + }, + new Object[][]{ + {"id", "integer", false, "nextval('orders_id_seq'::regclass)", "identifier", 32, 0, null}, + {"create_time", "timestamp with time zone", true, null, null, null, null, null}, + {"name", "character varying(64)", true, null, "display name", null, null, 64} + } + ) + )); + + List columns = agent.getColumns("public", "orders"); + + Assertions.assertEquals(3, columns.size()); + Assertions.assertEquals("integer", columns.get(0).getData_type()); + Assertions.assertTrue(columns.get(0).getIs_primary_key()); + Assertions.assertFalse(columns.get(0).getIs_nullable()); + Assertions.assertEquals("timestamp with time zone", columns.get(1).getData_type()); + Assertions.assertNotEquals("USER-DEFINED", columns.get(1).getData_type()); + Assertions.assertEquals(Integer.valueOf(64), columns.get(2).getCharacter_maximum_length()); + Assertions.assertTrue(sql.get(1).contains("format_type(a.atttypid, a.atttypmod) AS data_type"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("FROM sys_catalog.sys_attribute"), sql.get(1)); + Assertions.assertFalse(sql.get(1).contains("information_schema.columns"), sql.get(1)); + } + + @Test + void mysqlCompatGetColumnsKeepsInformationSchemaPath() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + agent.setMysqlCompatMode(true); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet( + new String[]{"column_name"}, + new Object[][]{{"id"}} + ), + resultSet( + new String[]{ + "column_name", + "data_type", + "is_nullable", + "column_default", + "numeric_precision", + "numeric_scale", + "character_maximum_length" + }, + new Object[][]{{"id", "int", "NO", null, 32, 0, null}} + ) + )); + + List columns = agent.getColumns("PUBLIC", "orders"); + + Assertions.assertEquals("int", columns.get(0).getData_type()); + Assertions.assertTrue(sql.get(1).contains("FROM information_schema.columns"), sql.get(1)); + } + + @Test + void regularListIndexesIncludesPrimaryUniqueAndSecondaryIndexes() { + List sql = new ArrayList<>(); + KingbaseAgent agent = new KingbaseAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"index_name", "index_type", "is_unique", "is_primary", "column_name", "ordinal_position"}, + new Object[][]{ + {"orders_pkey", "btree", true, true, "id", 1}, + {"idx_orders_created", "btree", false, false, "created", 1}, + {"idx_orders_name_created", "btree", false, false, "name", 1}, + {"idx_orders_name_created", "btree", false, false, "created", 2} + } + ))); + + List indexes = agent.listIndexes("public", "orders"); + + Assertions.assertEquals(3, indexes.size()); + Assertions.assertEquals("orders_pkey", indexes.get(0).getName()); + Assertions.assertEquals(Arrays.asList("id"), indexes.get(0).getColumns()); + Assertions.assertTrue(indexes.get(0).getIs_unique()); + Assertions.assertTrue(indexes.get(0).getIs_primary()); + Assertions.assertEquals("idx_orders_created", indexes.get(1).getName()); + Assertions.assertEquals(Arrays.asList("created"), indexes.get(1).getColumns()); + Assertions.assertFalse(indexes.get(1).getIs_unique()); + Assertions.assertFalse(indexes.get(1).getIs_primary()); + Assertions.assertEquals(Arrays.asList("name", "created"), indexes.get(2).getColumns()); + Assertions.assertTrue(sql.get(0).contains("FROM SYS_CATALOG.SYS_INDEX"), sql.get(0)); + Assertions.assertFalse(sql.get(0).contains("information_schema.table_constraints"), sql.get(0)); + } + + @Test + void mysqlCompatTimestampTypeNameIsReadAsTimestampText() throws Exception { + Timestamp timestamp = Timestamp.valueOf("2026-06-22 11:29:00"); + KingbaseAgent agent = new KingbaseAgent(); + agent.setMysqlCompatMode(true); + + Object value = readResultValue(agent, timestampResultSet(timestamp), Types.BINARY, "timestamp"); + + Assertions.assertEquals("2026-06-22 11:29:00.0", value); + } + + private static Connection preparedConnection(List sql, ResultSet rs) { + return preparedConnection(sql, new ResultSet[]{rs}); + } + + private static Connection preparedConnection(List sql, ResultSet... resultSets) { + int[] resultSetIndex = {0}; + PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("setString".equals(method.getName())) { + return null; + } + if ("close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + Statement plainStatement = proxy(Statement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return statement; + } + if ("createStatement".equals(method.getName())) { + return plainStatement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection preparedConnectionWithFailure(List sql, String failingSqlFragment, ResultSet fallback) { + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String preparedSql = String.valueOf(args[0]); + sql.add(preparedSql); + return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + if (preparedSql.contains(failingSqlFragment)) { + throw new SQLException("relation does not exist: " + failingSqlFragment); + } + return fallback; + } + if ("close".equals(statementMethod.getName())) { + return null; + } + return defaultValue(statementMethod.getReturnType()); + }); + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection postgresCatalogConnection(List sql, ResultSet metadataResult) { + return proxy(Connection.class, (method, args) -> { + if ("createStatement".equals(method.getName())) { + return proxy(Statement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) { + String query = String.valueOf(statementArgs[0]); + sql.add(query); + if (query.contains("sys_catalog.sys_namespace")) { + throw new SQLException("relation does not exist: sys_catalog.sys_namespace"); + } + return resultSet(new String[]{"probe"}, new Object[][]{}); + } + return defaultValue(statementMethod.getReturnType()); + }); + } + if ("prepareStatement".equals(method.getName())) { + String query = String.valueOf(args[0]); + sql.add(query); + return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> { + if ("executeQuery".equals(statementMethod.getName())) return metadataResult; + return defaultValue(statementMethod.getReturnType()); + }); + } + if ("isClosed".equals(method.getName())) return false; + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet resultSet(String[] columns, Object[][] rows) { + int[] index = {-1}; + return proxy(ResultSet.class, (method, args) -> { + switch (method.getName()) { + case "next": + index[0] += 1; + return index[0] < rows.length; + case "getString": + Object key = args[0]; + if (key instanceof Number) { + return rows[index[0]][((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return rows[index[0]][i]; + } + } + return null; + case "getBoolean": + Object booleanValue = columnValue(columns, rows[index[0]], args[0]); + if (booleanValue instanceof Boolean) return booleanValue; + if (booleanValue instanceof Number) return ((Number) booleanValue).intValue() != 0; + return Boolean.parseBoolean(String.valueOf(booleanValue)); + case "getObject": + return columnValue(columns, rows[index[0]], args[0]); + case "wasNull": + return false; + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static Object columnValue(String[] columns, Object[] row, Object key) { + if (key instanceof Number) { + return row[((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return row[i]; + } + } + return null; + } + + private static ResultSet timestampResultSet(Timestamp timestamp) { + return proxy(ResultSet.class, (method, args) -> { + switch (method.getName()) { + case "getTimestamp": + return timestamp; + case "getBytes": + throw new AssertionError("timestamp should not be read as bytes"); + case "wasNull": + return false; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = (Object unused, Method method, Object[] args) -> handler.handle(method, args); + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) return false; + if (Byte.TYPE.equals(type)) return (byte) 0; + if (Short.TYPE.equals(type)) return (short) 0; + if (Integer.TYPE.equals(type)) return 0; + if (Long.TYPE.equals(type)) return 0L; + if (Float.TYPE.equals(type)) return 0f; + if (Double.TYPE.equals(type)) return 0.0d; + if (Character.TYPE.equals(type)) return '\0'; + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } + + private static Object readResultValue(KingbaseAgent agent, ResultSet rs, int sqlType, String columnTypeName) throws Exception { + Method method = KingbaseAgent.class.getDeclaredMethod("resultValue", ResultSet.class, int.class, int.class, String.class); + method.setAccessible(true); + return method.invoke(agent, rs, 1, sqlType, columnTypeName); + } +} diff --git a/agents/drivers/kylin/build.gradle b/agents/drivers/kylin/build.gradle new file mode 100644 index 0000000..7ec3cd6 --- /dev/null +++ b/agents/drivers/kylin/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.apache.kylin:kylin-jdbc:5.0.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Apache Kylin', 'Main-Class': 'com.dbx.agent.kylin.KylinAgent') + } +} diff --git a/agents/drivers/kylin/src/main/java/com/dbx/agent/kylin/KylinAgent.java b/agents/drivers/kylin/src/main/java/com/dbx/agent/kylin/KylinAgent.java new file mode 100644 index 0000000..8af270c --- /dev/null +++ b/agents/drivers/kylin/src/main/java/com/dbx/agent/kylin/KylinAgent.java @@ -0,0 +1,144 @@ +package com.dbx.agent.kylin; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class KylinAgent extends AbstractJdbcAgent { + + @Override + protected String driverClass() { + return "org.apache.kylin.jdbc.Driver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (ResultSet rs = requireConnected().getMetaData().getCatalogs()) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString("TABLE_CAT"))); + } + } + result.sort(Comparator.comparing(DatabaseInfo::getName)); + return result; + }); + } + + @Override + public List listSchemas() { + return Collections.emptyList(); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (ResultSet rs = requireConnected().getMetaData().getTables(null, schema, null, null)) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("TABLE_NAME"), + normalizeTableType(rs.getString("TABLE_TYPE")), + null + )); + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + return result; + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Set primaryKeys = new LinkedHashSet<>(); + try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) { + while (rs.next()) { + primaryKeys.add(rs.getString("COLUMN_NAME")); + } + } + + List result = new ArrayList<>(); + try (ResultSet rs = requireConnected().getMetaData().getColumns(null, schema, table, null)) { + while (rs.next()) { + String colName = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + colName, + rs.getString("TYPE_NAME"), + "YES".equals(rs.getString("IS_NULLABLE")), + rs.getString("COLUMN_DEF"), + primaryKeys.contains(colName), + null, + emptyToNull(rs.getString("REMARKS")), + intOrNull(rs, "COLUMN_SIZE"), + intOrNull(rs, "DECIMAL_DIGITS"), + null + )); + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + return ""; + } + + private static String buildUrl(ConnectParams params) { + return "jdbc:kylin://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase(); + } + + private static String normalizeTableType(String type) { + if ("BASE TABLE".equals(type)) { + return "TABLE"; + } + return type; + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + public static void main(String[] args) { + new JsonRpcServer(new KylinAgent()).run(); + } +} diff --git a/agents/drivers/kylin/src/test/java/com/dbx/agent/kylin/KylinAgentTest.java b/agents/drivers/kylin/src/test/java/com/dbx/agent/kylin/KylinAgentTest.java new file mode 100644 index 0000000..4c3e88b --- /dev/null +++ b/agents/drivers/kylin/src/test/java/com/dbx/agent/kylin/KylinAgentTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.kylin; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; + +class KylinAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new KylinAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } +} diff --git a/agents/drivers/mongodb/build.gradle b/agents/drivers/mongodb/build.gradle new file mode 100644 index 0000000..125b760 --- /dev/null +++ b/agents/drivers/mongodb/build.gradle @@ -0,0 +1,11 @@ +dependencies { + implementation project(':common') + implementation 'org.mongodb:mongodb-driver-sync:3.12.14' + implementation 'com.google.code.gson:gson:2.11.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'MongoDB (Legacy)', 'Main-Class': 'com.dbx.agent.mongodb.MongoAgent') + } +} diff --git a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java new file mode 100644 index 0000000..b5b883f --- /dev/null +++ b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java @@ -0,0 +1,927 @@ +package com.dbx.agent.mongodb; + +import com.dbx.agent.AgentProtocol; +import com.dbx.agent.IndexInfo; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mongodb.ConnectionString; +import com.mongodb.MongoCredential; +import com.mongodb.MongoClientSettings; +import com.mongodb.ServerAddress; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.model.UpdateOptions; +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import org.bson.Document; +import org.bson.json.JsonMode; +import org.bson.json.JsonWriterSettings; +import org.bson.types.ObjectId; + +public final class MongoAgent { + private static final Gson GSON = new Gson(); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC); + private static final long JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991L; + private static final JsonWriterSettings EXTENDED_JSON_SETTINGS = JsonWriterSettings.builder() + .outputMode(JsonMode.RELAXED) + .build(); + private static MongoClient client; + + private MongoAgent() { + } + + private static String stringOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element instanceof JsonNull ? null : element.getAsString(); + } + + static Document documentOrNull(JsonObject object, String key) { + String json = stringOrNull(object, key); + return json == null || json.isBlank() ? null : Document.parse(json); + } + + static MongoClientSettings.Builder configureBuilder(JsonObject connObj) { + String host = connObj.has("host") ? connObj.get("host").getAsString() : "127.0.0.1"; + int port = connObj.has("port") ? connObj.get("port").getAsInt() : 27017; + String username = coalesce(stringOrNull(connObj, "username")); + String password = coalesce(stringOrNull(connObj, "password")); + String authDatabase = authenticationDatabase(connObj); + String connectionString = stringOrNull(connObj, "connection_string"); + boolean ssl = connObj.has("ssl") && !connObj.get("ssl").isJsonNull() && connObj.get("ssl").getAsBoolean(); + String caCertPath = stringOrNull(connObj, "ca_cert_path"); + String clientCertPath = firstNonBlank( + stringOrNull(connObj, "client_cert_path"), stringOrNull(connObj, "cert_path")); + String clientKeyPath = firstNonBlank( + stringOrNull(connObj, "client_key_path"), stringOrNull(connObj, "key_path")); + + if ((clientCertPath == null) != (clientKeyPath == null)) { + throw new IllegalArgumentException("Client certificate and key must be provided together"); + } + + MongoClientSettings.Builder builder = MongoClientSettings.builder(); + if (connectionString != null && !connectionString.isBlank()) { + builder.applyConnectionString(new ConnectionString(connectionString)); + } else { + builder.applyToClusterSettings( + settings -> settings.hosts(Collections.singletonList(new ServerAddress(host, port)))); + if (!username.isBlank()) { + builder.credential(MongoCredential.createCredential(username, authDatabase, password.toCharArray())); + } + } + + if (ssl) { + applyTlsSettings(builder, caCertPath, clientCertPath, clientKeyPath); + } + + return builder; + } + + private static Object connect(JsonObject params) { + JsonObject connObj = params.has("connection") && params.get("connection").isJsonObject() + ? params.getAsJsonObject("connection") + : params; + String database = defaultString(stringOrNull(connObj, "database"), "admin"); + + MongoClientSettings.Builder builder = configureBuilder(connObj); + + if (client != null) { + client.close(); + } + client = MongoClients.create(builder.build()); + client.getDatabase(database).runCommand(new Document("ping", 1)); + return Collections.singletonMap("ok", true); + } + + private static void applyTlsSettings(MongoClientSettings.Builder builder, + String caCertPath, String clientCertPath, String clientKeyPath) { + builder.applyToSslSettings(sslBuilder -> { + sslBuilder.enabled(true); + if (caCertPath != null && !caCertPath.isBlank() + || clientCertPath != null && !clientCertPath.isBlank()) { + try { + sslBuilder.context(createTlsSslContext(caCertPath, clientCertPath, clientKeyPath)); + } catch (Exception e) { + throw new RuntimeException("Failed to configure TLS: " + e.getMessage(), e); + } + } + }); + } + + static SSLContext createTlsSslContext(String caCertPath, String clientCertPath, String clientKeyPath) + throws Exception { + TrustManager[] trustManagers = null; + if (caCertPath != null && !caCertPath.isBlank()) { + trustManagers = loadTrustManagersFromPem(caCertPath); + } + + KeyManager[] keyManagers = null; + if (clientCertPath != null && !clientCertPath.isBlank() + && clientKeyPath != null && !clientKeyPath.isBlank()) { + keyManagers = loadKeyManagersFromPem(clientCertPath, clientKeyPath); + } + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagers, trustManagers, new SecureRandom()); + return ctx; + } + + static TrustManager[] loadTrustManagersFromPem(String caCertPath) throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + int i = 0; + try (InputStream is = new FileInputStream(caCertPath)) { + for (Certificate cert : (Collection) cf.generateCertificates(is)) { + trustStore.setCertificateEntry("ca-" + i, cert); + i++; + } + } + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + return tmf.getTrustManagers(); + } + + static KeyManager[] loadKeyManagersFromPem(String certPath, String keyPath) throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Certificate cert; + try (InputStream is = new FileInputStream(certPath)) { + cert = cf.generateCertificate(is); + } + + PrivateKey key = loadPrivateKeyFromPem(keyPath); + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setCertificateEntry("client", cert); + keyStore.setKeyEntry("client", key, new char[0], new Certificate[] {cert}); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, new char[0]); + return kmf.getKeyManagers(); + } + + static PrivateKey loadPrivateKeyFromPem(String keyPath) throws Exception { + String content = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + content = content.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN EC PRIVATE KEY-----", "") + .replace("-----END EC PRIVATE KEY-----", ""); + byte[] keyBytes = Base64.getDecoder().decode(content.replaceAll("\\s", "")); + + // PKCS#8 (standard format, "-----BEGIN PRIVATE KEY-----") + try { + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } catch (Exception e) { + // ignore — try next format + } + try { + return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } catch (Exception e) { + // ignore — try next format + } + + // PKCS#1 RSA — add PKCS#8 AlgorithmIdentifier prefix + // The prefix is: SEQUENCE { INTEGER 0, SEQUENCE { OID 1.2.840.113549.1.1.1, NULL }, OCTET STRING } + try { + byte[] pkcs8Header = { + 0x30, (byte) 0x82, 0, 0, // SEQUENCE (length filled in below) + 0x02, 0x01, 0x00, // INTEGER 0 + 0x30, 0x0d, // SEQUENCE (AlgorithmIdentifier) + 0x06, 0x09, 0x2a, (byte) 0x86, 0x48, (byte) 0x86, (byte) 0xf7, 0x0d, 0x01, 0x01, 0x01, // OID 1.2.840.113549.1.1.1 + 0x05, 0x00, // NULL + 0x04 // OCTET STRING (length filled in below) + }; + int totalLen = pkcs8Header.length + keyBytes.length - 4; // subtract placeholder SEQUENCE length + pkcs8Header[2] = (byte) ((totalLen >> 8) & 0xff); + pkcs8Header[3] = (byte) (totalLen & 0xff); + // OCTET STRING length + int octetLen = keyBytes.length; + byte[] octetLenBytes; + if (octetLen < 128) { + octetLenBytes = new byte[] {(byte) octetLen}; + } else if (octetLen < 256) { + octetLenBytes = new byte[] {(byte) 0x81, (byte) octetLen}; + } else { + octetLenBytes = new byte[] {(byte) 0x82, (byte) (octetLen >> 8), (byte) (octetLen & 0xff)}; + } + byte[] pkcs8Key = new byte[pkcs8Header.length + octetLenBytes.length - 1 + keyBytes.length]; + int pos = 0; + System.arraycopy(pkcs8Header, 0, pkcs8Key, pos, pkcs8Header.length - 1); // exclude placeholder OCTET STRING length + pos += pkcs8Header.length - 1; + System.arraycopy(octetLenBytes, 0, pkcs8Key, pos, octetLenBytes.length); + pos += octetLenBytes.length; + System.arraycopy(keyBytes, 0, pkcs8Key, pos, keyBytes.length); + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8Key)); + } catch (Exception e) { + throw new IllegalArgumentException( + "Unsupported private key format in " + keyPath + + ". Use PKCS#8 (-----BEGIN PRIVATE KEY-----) or PKCS#1 RSA (-----BEGIN RSA PRIVATE KEY-----).", + e); + } + } + + static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + static String authenticationDatabase(JsonObject connObj) { + String authSource = urlParam(stringOrNull(connObj, "url_params"), "authSource"); + if (authSource != null && !authSource.isBlank()) { + return authSource; + } + return "admin"; + } + + private static String urlParam(String urlParams, String key) { + if (urlParams == null || urlParams.isBlank()) { + return null; + } + String normalized = urlParams.startsWith("?") ? urlParams.substring(1) : urlParams; + for (String pair : normalized.split("&")) { + if (pair.isBlank()) continue; + String[] parts = pair.split("=", 2); + if (decode(parts[0]).equals(key)) { + return parts.length > 1 ? decode(parts[1]) : ""; + } + } + return null; + } + + private static String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static Object listDatabases() { + MongoClient c = requireClient(); + List> result = new ArrayList<>(); + for (String name : c.listDatabaseNames()) { + result.add(Collections.singletonMap("name", name)); + } + return result; + } + + private static Object listCollections(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + List result = new ArrayList<>(); + for (String name : c.getDatabase(database).listCollectionNames()) { + result.add(name); + } + return result; + } + + private static Object listIndexes(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("table").getAsString(); + List result = new ArrayList<>(); + for (Document index : c.getDatabase(database).getCollection(collection).listIndexes()) { + result.add(indexInfoFromDocument(index)); + } + return result; + } + + static IndexInfo indexInfoFromDocument(Document index) { + Document keys = index.get("key") instanceof Document document ? document : new Document(); + String name = index.getString("name"); + if (name == null || name.isBlank()) { + List parts = new ArrayList<>(); + for (Map.Entry entry : keys.entrySet()) { + parts.add(entry.getKey() + "_" + String.valueOf(entry.getValue())); + } + name = String.join("_", parts); + } + + List columns = new ArrayList<>(keys.keySet()); + String indexType = null; + if (!keys.isEmpty()) { + List parts = new ArrayList<>(); + for (Map.Entry entry : keys.entrySet()) { + parts.add(entry.getKey() + ": " + String.valueOf(entry.getValue())); + } + indexType = String.join(", ", parts); + } + + Object unique = index.get("unique"); + Document filter = index.get("partialFilterExpression") instanceof Document document ? document : null; + return new IndexInfo( + name, + columns, + unique instanceof Boolean && (Boolean) unique, + "_id_".equals(name), + filter == null ? null : filter.toJson(), + indexType, + null, + null + ); + } + + private static Object findDocuments(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + long skip = params.has("skip") ? params.get("skip").getAsLong() : 0; + int limit = params.has("limit") ? params.get("limit").getAsInt() : 50; + Document filterDoc = documentOrNull(params, "filter"); + Document projectionDoc = documentOrNull(params, "projection"); + Document sortDoc = documentOrNull(params, "sort"); + + var col = c.getDatabase(database).getCollection(collection); + if (filterDoc == null) { + filterDoc = new Document(); + } + long total = col.countDocuments(filterDoc); + + var iterable = col.find(filterDoc).skip((int) skip).limit(limit); + if (projectionDoc != null) { + iterable = iterable.projection(projectionDoc); + } + if (sortDoc != null) { + iterable = iterable.sort(sortDoc); + } + + List> documents = new ArrayList<>(); + for (Document document : iterable) { + documents.add(bsonToJson(document)); + } + Map result = new LinkedHashMap<>(); + result.put("documents", documents); + result.put("total", total); + return result; + } + + /** + * MongoDB Extended JSON read path for transfer; output follows the driver's + * relaxed Extended JSON representation rather than the UI display format. + */ + private static Object findDocumentsExtendedJson(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + long skip = params.has("skip") ? params.get("skip").getAsLong() : 0; + int limit = params.has("limit") ? params.get("limit").getAsInt() : 50; + Document filterDoc = documentOrNull(params, "filter"); + Document projectionDoc = documentOrNull(params, "projection"); + Document sortDoc = documentOrNull(params, "sort"); + + var col = c.getDatabase(database).getCollection(collection); + if (filterDoc == null) { + filterDoc = new Document(); + } + long total = col.countDocuments(filterDoc); + + var iterable = col.find(filterDoc).skip((int) skip).limit(limit); + if (projectionDoc != null) { + iterable = iterable.projection(projectionDoc); + } + if (sortDoc != null) { + iterable = iterable.sort(sortDoc); + } + + List documents = new ArrayList<>(); + for (Document document : iterable) { + documents.add(bsonToExtendedJson(document)); + } + Map result = new LinkedHashMap<>(); + result.put("documents", documents); + result.put("total", total); + return result; + } + + private static Object serverVersion(JsonObject params) { + MongoClient c = requireClient(); + String database = defaultString(stringOrNull(params, "database"), "admin"); + Document buildInfo = c.getDatabase(database).runCommand(new Document("buildInfo", 1)); + return serverVersionFromBuildInfo(buildInfo); + } + + static String serverVersionFromBuildInfo(Document buildInfo) { + String version = buildInfo.getString("version"); + if (version == null || version.isBlank()) { + throw new IllegalStateException("MongoDB server version not found"); + } + return version; + } + + private static Object createIndex(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + Document keys = requiredDocument(params, "keys_json", "Index keys"); + if (keys.isEmpty()) { + throw new IllegalArgumentException("Index keys are required"); + } + + Document index = new Document("key", keys); + Document options = documentOrNull(params, "options_json"); + if (options != null) { + index.putAll(options); + } + String name = index.getString("name"); + if (name == null || name.isBlank()) { + name = defaultIndexName(keys); + index.put("name", name); + } + + c.getDatabase(database).runCommand( + new Document("createIndexes", collection) + .append("indexes", Collections.singletonList(index)) + ); + return Collections.singletonMap("name", name); + } + + private static Document requiredDocument(JsonObject params, String key, String label) { + Document document = documentOrNull(params, key); + if (document == null) { + throw new IllegalArgumentException(label + " are required"); + } + return document; + } + + private static String defaultIndexName(Document keys) { + List parts = new ArrayList<>(); + for (Map.Entry entry : keys.entrySet()) { + parts.add(entry.getKey() + "_" + String.valueOf(entry.getValue())); + } + return String.join("_", parts); + } + + private static Object dropIndexes(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String indexesJson = stringOrNull(params, "indexes_json"); + boolean single = params.has("single") && !params.get("single").isJsonNull() && params.get("single").getAsBoolean(); + Object index = parseDropIndexesValue(indexesJson, single); + + List before = listIndexInfos(c, database, collection); + c.getDatabase(database).runCommand(new Document("dropIndexes", collection).append("index", index)); + List after = listIndexInfos(c, database, collection); + List droppedNames = diffDroppedIndexNames(before, after); + Map result = new LinkedHashMap<>(); + result.put("dropped_names", droppedNames); + result.put("affected_rows", droppedNames.size()); + return result; + } + + private static Object dropCollection(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + c.getDatabase(database).getCollection(collection).drop(); + return Collections.singletonMap("ok", true); + } + + private static Object parseDropIndexesValue(String indexesJson, boolean single) { + if (indexesJson == null || indexesJson.isBlank()) { + if (single) { + throw new IllegalArgumentException("dropIndex requires a string index name or JSON document"); + } + return "*"; + } + + JsonElement value = JsonParser.parseString(indexesJson); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + String name = value.getAsString(); + if (name.isBlank()) { + throw new IllegalArgumentException("Index name is required"); + } + if (single && "*".equals(name)) { + throw new IllegalArgumentException("dropIndex does not accept \"*\"; use dropIndexes() or dropIndexes(\"*\") instead"); + } + return name; + } + if (value.isJsonObject()) { + JsonObject object = value.getAsJsonObject(); + if (object.size() == 0) { + throw new IllegalArgumentException("Index specification is required"); + } + return Document.parse(indexesJson); + } + if (value.isJsonArray()) { + if (single) { + throw new IllegalArgumentException("dropIndex only accepts a string index name or JSON document; arrays are not supported"); + } + List names = new ArrayList<>(); + value.getAsJsonArray().forEach(item -> { + if (!item.isJsonPrimitive() || !item.getAsJsonPrimitive().isString() || item.getAsString().isBlank()) { + throw new IllegalArgumentException("dropIndexes only accepts arrays of string index names"); + } + names.add(item.getAsString()); + }); + if (names.isEmpty()) { + throw new IllegalArgumentException("dropIndexes only accepts non-empty string arrays"); + } + return names; + } + if (single) { + throw new IllegalArgumentException("dropIndex only accepts a string index name or JSON document"); + } + throw new IllegalArgumentException("dropIndexes only accepts a string index name, JSON document, or string array"); + } + + private static List listIndexInfos(MongoClient c, String database, String collection) { + List result = new ArrayList<>(); + for (Document index : c.getDatabase(database).getCollection(collection).listIndexes()) { + result.add(indexInfoFromDocument(index)); + } + return result; + } + + private static List diffDroppedIndexNames(List before, List after) { + Set remaining = new HashSet<>(); + for (IndexInfo index : after) { + remaining.add(index.getName()); + } + List droppedNames = new ArrayList<>(); + for (IndexInfo index : before) { + if (!remaining.contains(index.getName())) { + droppedNames.add(index.getName()); + } + } + return droppedNames; + } + + private static Object insertDocument(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String docJson = params.get("doc_json").getAsString(); + + Document doc = Document.parse(docJson); + c.getDatabase(database).getCollection(collection).insertOne(doc); + Object insertedId = convertValue(doc.get("_id")); + return Collections.singletonMap("inserted_id", insertedId); + } + + private static Object parseId(String id) { + try { + return new ObjectId(id); + } catch (Exception e) { + return id; + } + } + + private static Object updateDocument(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String id = params.get("id").getAsString(); + String docJson = params.get("doc_json").getAsString(); + + var col = c.getDatabase(database).getCollection(collection); + Document newDoc = documentForWrite(docJson); + var filter = new Document("_id", parseId(id)); + var result = isUpdateOperatorDocument(newDoc) + ? col.updateOne(filter, newDoc) + : col.replaceOne(filter, replacementDocument(newDoc)); + return Collections.singletonMap("modified_count", result.getModifiedCount()); + } + + private static Object updateDocuments(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String filterJson = params.get("filter_json").getAsString(); + String updateJson = params.get("update_json").getAsString(); + boolean many = params.get("many").getAsBoolean(); + String optionsJson = params.has("options_json") && !params.get("options_json").isJsonNull() + ? params.get("options_json").getAsString() + : null; + + var col = c.getDatabase(database).getCollection(collection); + Document filter = documentForWrite(filterJson); + Document update = documentForWrite(updateJson); + requireBulkUpdateOperatorDocument(update); + UpdateOptions options = updateOptionsForWrite(optionsJson); + var result = many ? col.updateMany(filter, update, options) : col.updateOne(filter, update, options); + return Collections.singletonMap("modified_count", result.getModifiedCount()); + } + + static UpdateOptions updateOptionsForWrite(String optionsJson) { + UpdateOptions result = new UpdateOptions(); + if (optionsJson == null || optionsJson.trim().isEmpty()) { + return result; + } + Document options = Document.parse(optionsJson); + for (String key : options.keySet()) { + if (!"arrayFilters".equals(key)) { + throw new IllegalArgumentException("Unsupported update option: " + key); + } + } + Object rawFilters = options.get("arrayFilters"); + if (rawFilters == null) { + return result; + } + if (!(rawFilters instanceof List)) { + throw new IllegalArgumentException("arrayFilters must be an array"); + } + List filters = new ArrayList<>(); + for (Object filter : (List) rawFilters) { + if (!(filter instanceof Document)) { + throw new IllegalArgumentException("Each arrayFilters entry must be an object"); + } + filters.add((Document) filter); + } + return result.arrayFilters(filters); + } + + static Document documentForWrite(String docJson) { + Document doc = Document.parse(docJson); + convertMongoShellDates(doc); + return doc; + } + + private static Document replacementDocument(Document doc) { + Document replacement = new Document(doc); + replacement.remove("_id"); + return replacement; + } + + static boolean isUpdateOperatorDocument(Document doc) { + if (doc.isEmpty()) { + return false; + } + for (String key : doc.keySet()) { + if (!key.startsWith("$")) { + return false; + } + } + return true; + } + + static void requireBulkUpdateOperatorDocument(Document doc) { + if (!isUpdateOperatorDocument(doc)) { + // updateOne/updateMany are shell-style bulk updates here; replacements stay on the + // single-document save path so a broad filter cannot replace many documents by accident. + throw new IllegalArgumentException("Bulk update requires update operators such as $set"); + } + } + + private static Object deleteDocument(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String id = params.get("id").getAsString(); + + var col = c.getDatabase(database).getCollection(collection); + var result = col.deleteOne(new Document("_id", parseId(id))); + return Collections.singletonMap("deleted_count", result.getDeletedCount()); + } + + private static Object deleteDocuments(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String filterJson = params.get("filter_json").getAsString(); + boolean many = params.get("many").getAsBoolean(); + + var col = c.getDatabase(database).getCollection(collection); + Document filter = documentForWrite(filterJson); + // Shell deleteOne/deleteMany use a filter document, unlike the row-view + // delete path which always targets a single _id. + var result = many ? col.deleteMany(filter) : col.deleteOne(filter); + return Collections.singletonMap("deleted_count", result.getDeletedCount()); + } + + private static Map bsonToJson(Document doc) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : doc.entrySet()) { + result.put(entry.getKey(), convertValue(entry.getValue())); + } + return result; + } + + static JsonObject bsonToExtendedJson(Document doc) { + return JsonParser.parseString(doc.toJson(EXTENDED_JSON_SETTINGS)).getAsJsonObject(); + } + + static Object convertValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof ObjectId objectId) { + return objectId.toHexString(); + } + if (value instanceof Document document) { + return bsonToJson(document); + } + if (value instanceof List values) { + List result = new ArrayList<>(); + for (Object item : values) { + result.add(convertValue(item)); + } + return result; + } + if (value instanceof java.util.Date date) { + Instant instant = Instant.ofEpochMilli(date.getTime()); + return "ISODate(\"" + DATE_FORMAT.format(instant) + "\")"; + } + if (value instanceof Long longValue) { + return longValue < -JS_MAX_SAFE_INTEGER || longValue > JS_MAX_SAFE_INTEGER ? longValue.toString() : longValue; + } + if (value instanceof Boolean || value instanceof Integer || value instanceof Double || value instanceof String) { + return value; + } + return value.toString(); + } + + @SuppressWarnings("unchecked") + private static Object convertMongoShellDates(Object value) { + if (value instanceof Document document) { + for (String key : new ArrayList<>(document.keySet())) { + document.put(key, convertMongoShellDates(document.get(key))); + } + return document; + } + if (value instanceof List values) { + List converted = (List) values; + for (int i = 0; i < converted.size(); i++) { + converted.set(i, convertMongoShellDates(converted.get(i))); + } + return converted; + } + if (value instanceof String text) { + Date date = parseMongoShellDate(text); + if (date == null) { + date = parseLegacyDateDisplay(text); + } + return date == null ? value : date; + } + return value; + } + + static Date parseMongoShellDate(String value) { + String trimmed = value.trim(); + String inner = null; + if (trimmed.startsWith("ISODate(") && trimmed.endsWith(")")) { + inner = trimmed.substring("ISODate(".length(), trimmed.length() - 1).trim(); + } else if (trimmed.startsWith("new Date(") && trimmed.endsWith(")")) { + inner = trimmed.substring("new Date(".length(), trimmed.length() - 1).trim(); + } + if (inner == null || inner.length() < 2) { + return null; + } + char quote = inner.charAt(0); + if ((quote != '"' && quote != '\'') || inner.charAt(inner.length() - 1) != quote) { + return null; + } + try { + Instant instant = Instant.parse(inner.substring(1, inner.length() - 1)); + return Date.from(instant); + } catch (Exception e) { + return null; + } + } + + static Date parseLegacyDateDisplay(String value) { + String trimmed = value.trim(); + if (!trimmed.matches("\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?")) { + return null; + } + String normalized = trimmed.replace(' ', 'T'); + int dot = normalized.indexOf('.'); + if (dot < 0) { + normalized = normalized + ".000"; + } else { + int millisStart = dot + 1; + int millisEnd = normalized.length(); + normalized = normalized.substring(0, millisStart) + + String.format("%-3s", normalized.substring(millisStart, millisEnd)).replace(' ', '0'); + } + try { + return Date.from(Instant.parse(normalized + "Z")); + } catch (Exception e) { + return null; + } + } + + private static Object dispatch(String method, JsonObject params) { + return switch (method) { + case AgentProtocol.METHOD_HANDSHAKE -> AgentProtocol.handshakeResult(); + case AgentProtocol.METHOD_CONNECT -> connect(params); + case AgentProtocol.MONGO_METHOD_LIST_DATABASES -> listDatabases(); + case AgentProtocol.MONGO_METHOD_LIST_COLLECTIONS -> listCollections(params); + case AgentProtocol.METHOD_LIST_INDEXES -> listIndexes(params); + case AgentProtocol.MONGO_METHOD_FIND_DOCUMENTS -> findDocuments(params); + case AgentProtocol.MONGO_METHOD_FIND_DOCUMENTS_EXTENDED_JSON -> findDocumentsExtendedJson(params); + case AgentProtocol.MONGO_METHOD_SERVER_VERSION -> serverVersion(params); + case AgentProtocol.MONGO_METHOD_CREATE_INDEX -> createIndex(params); + case AgentProtocol.MONGO_METHOD_DROP_INDEXES -> dropIndexes(params); + case AgentProtocol.MONGO_METHOD_DROP_COLLECTION -> dropCollection(params); + case AgentProtocol.MONGO_METHOD_INSERT_DOCUMENT -> insertDocument(params); + case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENT -> updateDocument(params); + case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENTS -> updateDocuments(params); + case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENT -> deleteDocument(params); + case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENTS -> deleteDocuments(params); + case AgentProtocol.METHOD_DISCONNECT, AgentProtocol.METHOD_SHUTDOWN -> { + if (client != null) { + client.close(); + client = null; + } + if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) { + System.exit(0); + } + yield Collections.singletonMap("ok", true); + } + default -> throw new IllegalArgumentException("Unknown method: " + method); + }; + } + + private static MongoClient requireClient() { + if (client == null) { + throw new IllegalStateException("Not connected"); + } + return client; + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + private static String defaultString(String value, String fallback) { + return value == null ? fallback : value; + } + + static String handleRequest(String line) { + JsonObject req = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = req.get("id"); + String method = req.get("method").getAsString(); + JsonObject params = req.has("params") && req.get("params").isJsonObject() + ? req.getAsJsonObject("params") + : new JsonObject(); + + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + response.add("id", id); + + try { + Object result = dispatch(method, params); + response.add("result", GSON.toJsonTree(result)); + } catch (Exception e) { + JsonObject error = new JsonObject(); + error.addProperty("code", -1); + error.addProperty("message", e.getMessage() == null ? "Unknown error" : e.getMessage()); + response.add("error", error); + } + + return GSON.toJson(response); + } + + public static void main(String[] args) throws Exception { + System.out.println("{\"ready\":true}"); + System.out.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + while (true) { + String line = reader.readLine(); + if (line == null) { + break; + } + + System.out.println(handleRequest(line)); + System.out.flush(); + } + } +} diff --git a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java new file mode 100644 index 0000000..a90406c --- /dev/null +++ b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java @@ -0,0 +1,517 @@ +package com.dbx.agent.mongodb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.dbx.agent.AgentProtocol; +import com.dbx.agent.IndexInfo; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.model.UpdateOptions; +import java.io.FileInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.util.Base64; +import java.util.Date; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MongoAgentTest { + @TempDir + static Path tempDir; + + private static Path caPemPath; + private static Path clientPemPath; + private static Path clientKeyPath; + + @BeforeAll + static void setUpCerts() throws Exception { + Path keystore = tempDir.resolve("keystore.jks"); + caPemPath = tempDir.resolve("ca.pem"); + clientPemPath = tempDir.resolve("client.pem"); + clientKeyPath = tempDir.resolve("client-key.pem"); + + // Generate a key pair in a JKS keystore using keytool + ProcessBuilder pb = new ProcessBuilder( + "keytool", "-genkeypair", "-alias", "test", "-keyalg", "RSA", "-keysize", "2048", + "-keystore", keystore.toString(), "-storepass", "pass123", "-keypass", "pass123", + "-dname", "CN=Test TLS Cert", "-validity", "365" + ); + pb.inheritIO(); + int rc = pb.start().waitFor(); + if (rc != 0) { + throw new RuntimeException("keytool -genkeypair failed with exit code " + rc); + } + + // Export the certificate as PEM (for ca_cert_path / client_cert_path) + for (Path pem : new Path[] {caPemPath, clientPemPath}) { + ProcessBuilder exportPb = new ProcessBuilder( + "keytool", "-exportcert", "-alias", "test", + "-keystore", keystore.toString(), "-storepass", "pass123", "-rfc" + ); + exportPb.redirectOutput(pem.toFile()); + exportPb.redirectError(ProcessBuilder.Redirect.INHERIT); + int exportRc = exportPb.start().waitFor(); + if (exportRc != 0) { + throw new RuntimeException("keytool -exportcert failed with exit code " + exportRc); + } + } + + // Extract the private key as PKCS#8 PEM + KeyStore ks = KeyStore.getInstance("JKS"); + try (FileInputStream fis = new FileInputStream(keystore.toFile())) { + ks.load(fis, "pass123".toCharArray()); + } + PrivateKey pk = (PrivateKey) ks.getKey("test", "pass123".toCharArray()); + String pkcs8Pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getEncoder().encodeToString(pk.getEncoded()) + + "\n-----END PRIVATE KEY-----\n"; + Files.writeString(clientKeyPath, pkcs8Pem); + } + + // ─── existing tests ─── + + @Test + void exposesProtocolHandshakeOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"handshake\"," + + "\"params\":{\"appVersion\":\"0.5.13\",\"supportedProtocolVersions\":[1]}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + JsonObject result = json.getAsJsonObject("result"); + assertEquals("2.0", json.get("jsonrpc").getAsString()); + assertEquals(7, json.get("id").getAsInt()); + assertEquals(AgentProtocol.PROTOCOL_VERSION, result.get("protocolVersion").getAsInt()); + assertEquals(AgentProtocol.PROTOCOL_VERSION, result.get("agentProtocolVersion").getAsInt()); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_CONNECT)); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_QUERY)); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_METADATA)); + } + + @Test + void listIndexesMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"list_indexes\"," + + "\"params\":{\"database\":\"app\",\"schema\":\"\",\"table\":\"orders\"}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(8, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + } + + @Test + void parsesOptionalDocumentParameters() { + JsonObject params = new JsonObject(); + params.addProperty("projection", "{\"title\":1,\"_id\":0}"); + params.addProperty("filter", ""); + + Document projection = MongoAgent.documentOrNull(params, "projection"); + + assertNotNull(projection); + assertEquals(1, projection.get("title")); + assertEquals(0, projection.get("_id")); + assertEquals(null, MongoAgent.documentOrNull(params, "filter")); + assertEquals(null, MongoAgent.documentOrNull(params, "sort")); + } + + @Test + void documentParametersParseExtendedJsonLongFilters() { + JsonObject params = new JsonObject(); + params.addProperty("filter", "{\"processInfoId\":{\"$numberLong\":\"2048938405781032962\"},\"snowflake\":{\"$numberLong\":\"9007199254740993\"}}"); + + Document filter = MongoAgent.documentOrNull(params, "filter"); + + assertNotNull(filter); + assertEquals(2_048_938_405_781_032_962L, filter.get("processInfoId")); + assertEquals(9_007_199_254_740_993L, filter.get("snowflake")); + } + + @Test + void serverVersionMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"server_version\"," + + "\"params\":{\"database\":\"admin\"}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(9, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + } + + @Test + void createIndexMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"create_index\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\"," + + "\"keys_json\":\"{\\\"email\\\":1}\",\"options_json\":\"{\\\"name\\\":\\\"email_1\\\",\\\"background\\\":true}\"}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(12, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_CREATE_INDEX)); + } + + @Test + void dropIndexesMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"drop_indexes\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\"," + + "\"indexes_json\":\"\\\"email_1\\\"\",\"single\":true}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(13, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DROP_INDEXES)); + } + + @Test + void dropCollectionMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":14,\"method\":\"drop_collection\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\"}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(14, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DROP_COLLECTION)); + } + + @Test + void updateDocumentsMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"update_documents\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\",\"filter_json\":\"{}\"," + + "\"update_json\":\"{\\\"$set\\\":{\\\"data\\\":null}}\",\"many\":true}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(10, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + } + + @Test + void parsesArrayFiltersUpdateOption() { + UpdateOptions options = MongoAgent.updateOptionsForWrite( + "{\"arrayFilters\":[{\"item.id\":322678}]}" + ); + + assertEquals(1, options.getArrayFilters().size()); + assertEquals(322678, ((Document) options.getArrayFilters().get(0)).getInteger("item.id")); + } + + @Test + void rejectsUnsupportedUpdateOptions() { + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> MongoAgent.updateOptionsForWrite("{\"upsert\":true}") + ); + assertEquals("Unsupported update option: upsert", error.getMessage()); + } + + @Test + void deleteDocumentsMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"delete_documents\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\"," + + "\"filter_json\":\"{\\\"status\\\":\\\"draft\\\"}\",\"many\":true}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(11, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method")); + assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DELETE_DOCUMENTS)); + } + + @Test + void extractsServerVersionFromBuildInfo() { + assertEquals("4.4.29", MongoAgent.serverVersionFromBuildInfo(new Document("version", "4.4.29"))); + assertThrows(IllegalStateException.class, () -> MongoAgent.serverVersionFromBuildInfo(new Document("ok", 1))); + } + + @Test + void convertsMongoIndexDocumentToIndexInfo() { + Document index = new Document("name", "idx_user_status") + .append("key", new Document("user_id", 1).append("status", -1)) + .append("unique", true) + .append("partialFilterExpression", new Document("deleted", false)); + + IndexInfo info = MongoAgent.indexInfoFromDocument(index); + + assertEquals("idx_user_status", info.getName()); + assertEquals(java.util.List.of("user_id", "status"), info.getColumns()); + assertEquals(true, info.getIs_unique()); + assertEquals(false, info.getIs_primary()); + assertEquals("user_id: 1, status: -1", info.getIndex_type()); + assertTrue(info.getFilter().contains("\"deleted\"")); + } + + @Test + void usesAuthSourceFromUrlParamsAsAuthenticationDatabase() { + JsonObject connection = new JsonObject(); + connection.addProperty("database", "gray_lite_twin_fat"); + connection.addProperty("url_params", "authSource=admin&authMechanism=SCRAM-SHA-1"); + + assertEquals("admin", MongoAgent.authenticationDatabase(connection)); + } + + @Test + void fallsBackToAdminWhenAuthSourceIsMissing() { + JsonObject connection = new JsonObject(); + connection.addProperty("database", "gray_lite_twin_fat"); + + assertEquals("admin", MongoAgent.authenticationDatabase(connection)); + } + + // ─── TLS: configureBuilder JSON parsing ─── + + @Test + void sslTrueFromConnectionObject() { + JsonObject connection = minimalConnection(); + connection.addProperty("ssl", true); + + MongoClientSettings.Builder builder = MongoAgent.configureBuilder(connection); + + assertNotNull(builder); + } + + @Test + void sslFalseByDefault() { + JsonObject connection = minimalConnection(); + // ssl is not set — should default to false + + MongoClientSettings.Builder builder = MongoAgent.configureBuilder(connection); + + assertNotNull(builder); + } + + @Test + void sslTrueFromTopLevelParams() { + JsonObject connObj = minimalConnection(); + connObj.addProperty("ssl", true); + JsonObject params = new JsonObject(); + params.add("connection", connObj); + + // connect() unwraps the connection sub-object; verify configureBuilder reads ssl from it + JsonObject extracted = params.has("connection") && params.get("connection").isJsonObject() + ? params.getAsJsonObject("connection") + : params; + assertEquals(true, extracted.get("ssl").getAsBoolean()); + } + + @Test + void readsCaCertPathFromConnection() { + JsonObject connection = minimalConnection(); + connection.addProperty("ssl", true); + connection.addProperty("ca_cert_path", caPemPath.toString()); + + MongoClientSettings.Builder builder = MongoAgent.configureBuilder(connection); + + assertNotNull(builder); + } + + @Test + void readsClientCertAndKeyPathsFromConnection() { + JsonObject connection = minimalConnection(); + connection.addProperty("ssl", true); + connection.addProperty("client_cert_path", clientPemPath.toString()); + connection.addProperty("client_key_path", clientKeyPath.toString()); + + MongoClientSettings.Builder builder = MongoAgent.configureBuilder(connection); + + assertNotNull(builder); + } + + @Test + void certPathAndKeyPathFallbackNames() { + JsonObject connection = minimalConnection(); + connection.addProperty("ssl", true); + connection.addProperty("cert_path", clientPemPath.toString()); + connection.addProperty("key_path", clientKeyPath.toString()); + + // Should not throw — cert_path/key_path are fallback names for client_cert_path/client_key_path + MongoClientSettings.Builder builder = MongoAgent.configureBuilder(connection); + + assertNotNull(builder); + } + + @Test + void rejectsMismatchedClientCertAndKey() { + JsonObject connection = minimalConnection(); + connection.addProperty("ssl", true); + connection.addProperty("client_cert_path", clientPemPath.toString()); + // client_key_path is missing + + assertThrows(IllegalArgumentException.class, () -> MongoAgent.configureBuilder(connection)); + } + + // ─── TLS: SSLContext creation ─── + + @Test + void createsSslContextWithCaCert() throws Exception { + var ctx = MongoAgent.createTlsSslContext(caPemPath.toString(), null, null); + + assertNotNull(ctx); + } + + @Test + void createsSslContextWithClientCertAndKey() throws Exception { + var ctx = MongoAgent.createTlsSslContext(null, clientPemPath.toString(), clientKeyPath.toString()); + + assertNotNull(ctx); + } + + @Test + void createsSslContextWithAllCertPaths() throws Exception { + var ctx = MongoAgent.createTlsSslContext( + caPemPath.toString(), clientPemPath.toString(), clientKeyPath.toString()); + + assertNotNull(ctx); + } + + // ─── TLS: trust manager loading ─── + + @Test + void loadsTrustManagersFromPemFile() throws Exception { + var trustManagers = MongoAgent.loadTrustManagersFromPem(caPemPath.toString()); + + assertNotNull(trustManagers); + assertTrue(trustManagers.length > 0); + } + + // ─── TLS: key manager loading ─── + + @Test + void loadsKeyManagersFromPemFiles() throws Exception { + var keyManagers = MongoAgent.loadKeyManagersFromPem( + clientPemPath.toString(), clientKeyPath.toString()); + + assertNotNull(keyManagers); + assertTrue(keyManagers.length > 0); + } + + // ─── TLS: private key format support ─── + + @Test + void loadsPkcs8PrivateKeyFromPem() throws Exception { + var key = MongoAgent.loadPrivateKeyFromPem(clientKeyPath.toString()); + + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + // ─── utility ─── + + @Test + void firstNonBlankReturnsFirstNonBlankValue() { + assertEquals("b", MongoAgent.firstNonBlank(null, "", "b", "c")); + assertEquals("a", MongoAgent.firstNonBlank("a", "b")); + } + + @Test + void firstNonBlankReturnsNullWhenAllBlank() { + assertEquals(null, MongoAgent.firstNonBlank(null, "", " ")); + } + + @Test + void convertValuePreservesUnsafeLongForJsonClients() { + assertEquals("2326645729978441729", MongoAgent.convertValue(2_326_645_729_978_441_729L)); + assertEquals("-2326645729978441729", MongoAgent.convertValue(-2_326_645_729_978_441_729L)); + } + + @Test + void convertValueKeepsSafeLongAsNumber() { + assertEquals(42L, MongoAgent.convertValue(42L)); + } + + @Test + void convertValueFormatsDatesAsMongoShellIsoDate() { + assertEquals("ISODate(\"2026-06-10T13:59:31.287Z\")", MongoAgent.convertValue(Date.from(java.time.Instant.parse("2026-06-10T13:59:31.287Z")))); + } + + @Test + void convertValueKeepsObjectIdAsStringByDefault() { + assertEquals( + "507f1f77bcf86cd799439011", + MongoAgent.convertValue(new ObjectId("507f1f77bcf86cd799439011")) + ); + } + + @Test + void bsonToExtendedJsonUsesMongoExtendedJson() { + Document doc = new Document("_id", new ObjectId("507f1f77bcf86cd799439011")) + .append("created_at", Date.from(java.time.Instant.parse("2026-06-10T13:59:31.287Z"))); + + assertEquals( + "{\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},\"created_at\":{\"$date\":\"2026-06-10T13:59:31.287Z\"}}", + new com.google.gson.Gson().toJson(MongoAgent.bsonToExtendedJson(doc)) + ); + } + + @Test + void documentForWriteParsesMongoShellIsoDateStrings() { + Document doc = MongoAgent.documentForWrite("{\"$set\":{\"CreateDate\":\"ISODate(\\\"2026-06-10T13:59:31.287Z\\\")\"}}"); + + assertTrue(MongoAgent.isUpdateOperatorDocument(doc)); + Document set = (Document) doc.get("$set"); + assertTrue(set.get("CreateDate") instanceof Date); + } + + @Test + void documentForWriteParsesNestedMongoShellIsoDateStrings() { + Document doc = MongoAgent.documentForWrite("{\"items\":[{\"created\":\"new Date(\\\"2026-06-10T13:59:31.287Z\\\")\"}]}"); + + assertTrue(((Document) ((java.util.List) doc.get("items")).get(0)).get("created") instanceof Date); + } + + @Test + void documentForWriteParsesLegacyDateDisplayStrings() { + Document doc = MongoAgent.documentForWrite("{\"$set\":{\"CreateDate\":\"2025-08-14 02:25:43.718\"}}"); + + Document set = (Document) doc.get("$set"); + assertTrue(set.get("CreateDate") instanceof Date); + assertEquals(1_755_138_343_718L, ((Date) set.get("CreateDate")).getTime()); + } + + @Test + void bulkUpdateRequiresOperatorDocument() { + Document update = MongoAgent.documentForWrite("{\"$set\":{\"data\":null}}"); + MongoAgent.requireBulkUpdateOperatorDocument(update); + + assertThrows( + IllegalArgumentException.class, + () -> MongoAgent.requireBulkUpdateOperatorDocument(MongoAgent.documentForWrite("{\"data\":null}")) + ); + } + + // ─── helpers ─── + + private static JsonObject minimalConnection() { + JsonObject conn = new JsonObject(); + conn.addProperty("host", "127.0.0.1"); + conn.addProperty("port", 27017); + return conn; + } + + private static boolean containsCapability(JsonArray capabilities, String expected) { + for (int i = 0; i < capabilities.size(); i++) { + if (expected.equals(capabilities.get(i).getAsString())) { + return true; + } + } + return false; + } +} diff --git a/agents/drivers/neo4j/build.gradle b/agents/drivers/neo4j/build.gradle new file mode 100644 index 0000000..232f595 --- /dev/null +++ b/agents/drivers/neo4j/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.neo4j:neo4j-jdbc-full-bundle:6.10.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Neo4j', 'Main-Class': 'com.dbx.agent.neo4j.Neo4jAgent') + } +} diff --git a/agents/drivers/neo4j/src/main/java/com/dbx/agent/neo4j/Neo4jAgent.java b/agents/drivers/neo4j/src/main/java/com/dbx/agent/neo4j/Neo4jAgent.java new file mode 100644 index 0000000..9447b38 --- /dev/null +++ b/agents/drivers/neo4j/src/main/java/com/dbx/agent/neo4j/Neo4jAgent.java @@ -0,0 +1,350 @@ +package com.dbx.agent.neo4j; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TransactionExecutor; +import com.dbx.agent.TriggerInfo; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class Neo4jAgent extends BaseDatabaseAgent { + private Connection connection; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public String setSchemaSQL(String schema) { + return ""; + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + Class.forName("org.neo4j.jdbc.Neo4jDriver"); + String url = buildNeo4jUrl(params); + connection = DriverManager.getConnection(url, params.getUsername(), params.getPassword()); + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + Class.forName("org.neo4j.jdbc.Neo4jDriver"); + String url = buildNeo4jUrl(params); + try (Connection conn = DriverManager.getConnection(url, params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + } + + private static String buildNeo4jUrl(ConnectParams params) { + StringBuilder url = new StringBuilder(); + url.append("jdbc:neo4j://").append(params.getHost()).append(":").append(params.getPort()); + String database = params.getDatabase(); + if (database != null && !database.trim().isEmpty()) { + url.append("?database=").append(database.trim()); + } + return url.toString(); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + Connection conn = requireConnected(); + List result = new ArrayList<>(); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString("name"))); + } + } + result.sort(Comparator.comparing(DatabaseInfo::getName)); + return result; + }); + } + + @Override + public List listSchemas() { + return Collections.emptyList(); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + Connection conn = requireConnected(); + // Try JDBC metadata first — the driver handles version compatibility. + List result = new ArrayList<>(); + DatabaseMetaData meta = conn.getMetaData(); + try (ResultSet rs = meta.getTables(null, blankToNull(schema), "%", new String[]{"TABLE", "VIEW"})) { + while (rs.next()) { + String name = rs.getString("TABLE_NAME"); + String type = rs.getString("TABLE_TYPE"); + String comment = getStringOrNull(rs, "REMARKS"); + if (name != null && !name.isEmpty()) { + result.add(new TableInfo(name, type, comment)); + } + } + } catch (Exception ignored) { + // Fall through to Cypher fallback + } + // Fallback: use Cypher for drivers that don't implement getTables(). + if (result.isEmpty()) { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("CALL db.labels()")) { + while (rs.next()) { + String label = getStringOrDefault(rs, "label", ""); + if (label.isEmpty()) { + label = rs.getString(1); + } + if (label != null && !label.isEmpty()) { + result.add(new TableInfo(label, "TABLE", null)); + } + } + } catch (Exception ignored) { + // Cypher fallback also failed + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + return result; + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Connection conn = requireConnected(); + List result = new ArrayList<>(); + // Try JDBC metadata first. + DatabaseMetaData meta = conn.getMetaData(); + Set primaryKeys = new LinkedHashSet<>(); + try (ResultSet rs = meta.getPrimaryKeys(null, blankToNull(schema), table)) { + while (rs.next()) { + String pkColumn = rs.getString("COLUMN_NAME"); + if (pkColumn != null) { + primaryKeys.add(pkColumn); + } + } + } catch (Exception ignored) { + // Fall through + } + try (ResultSet rs = meta.getColumns(null, blankToNull(schema), table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + if (name == null || name.isEmpty()) { + continue; + } + String type = rs.getString("TYPE_NAME"); + if (type == null) { + type = "Unknown"; + } + boolean nullable = true; + try { + nullable = "YES".equals(rs.getString("IS_NULLABLE")); + } catch (Exception ignored) { + // keep default + } + String defaultValue = getStringOrNull(rs, "COLUMN_DEF"); + String comment = getStringOrNull(rs, "REMARKS"); + result.add(new ColumnInfo( + name, type, nullable, defaultValue, + primaryKeys.contains(name), + comment, null, null, null, null + )); + } + } catch (Exception ignored) { + // Fall through to Cypher fallback + } + // Fallback: use Cypher for drivers that don't implement getColumns(). + if (result.isEmpty()) { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("CALL db.schema.nodeTypeProperties()")) { + while (rs.next()) { + String nodeLabels = rs.getString("nodeLabels"); + if (nodeLabels == null) { + nodeLabels = ""; + } + if (!nodeLabels.contains(table)) { + continue; + } + String propertyName = rs.getString("propertyName"); + if (propertyName == null) { + continue; + } + String propertyTypes = rs.getString("propertyTypes"); + if (propertyTypes == null) { + propertyTypes = "Unknown"; + } + boolean isNullable; + try { + isNullable = !rs.getBoolean("mandatory"); + } catch (Exception ignored) { + isNullable = true; + } + result.add(new ColumnInfo( + propertyName, propertyTypes, isNullable, + null, false, null, null, null, null, null + )); + } + } catch (Exception ignored) { + // Cypher fallback also failed + } + } + result.sort(Comparator.comparing(ColumnInfo::getName)); + return result; + }); + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + Connection conn = requireConnected(); + List result = new ArrayList<>(); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW INDEXES")) { + while (rs.next()) { + String labelsOrTypes = getStringOrDefault(rs, "labelsOrTypes", ""); + if (!labelsOrTypes.contains(table)) { + continue; + } + + String properties = getStringOrDefault(rs, "properties", ""); + String uniqueness = getStringOrDefault(rs, "uniqueness", ""); + result.add(new IndexInfo( + getStringOrDefault(rs, "name", ""), + parseProperties(properties), + "UNIQUE".equals(uniqueness.toUpperCase(Locale.ROOT)), + false, + null, + getStringOrNull(rs, "type"), + null, + null + )); + } + } + result.sort(Comparator.comparing(IndexInfo::getName)); + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public QueryResult executeTransaction(List statements, String schema) { + return TransactionExecutor.executeStatements(requireConnected(), statements, schema, this::setSchemaSQL, (stmt, sql) -> { + stmt.execute(sql); + return Math.max(stmt.getUpdateCount(), 0); + }); + } + + public QueryResult executeQuery(String sql, String schema) { + return executeQuery(sql, schema, new ExecuteQueryOptions()); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::stringResultValue + ); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private Object stringResultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + private static List parseProperties(String properties) { + String trimmed = properties; + if (trimmed.startsWith("[") && trimmed.endsWith("]") && trimmed.length() >= 2) { + trimmed = trimmed.substring(1, trimmed.length() - 1); + } + + List result = new ArrayList<>(); + for (String property : trimmed.split(",")) { + String value = property.trim(); + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } + if (!value.isEmpty()) { + result.add(value); + } + } + return result; + } + + private static String getStringOrDefault(ResultSet rs, String column, String defaultValue) { + try { + String value = rs.getString(column); + return value == null ? defaultValue : value; + } catch (Exception ignored) { + return defaultValue; + } + } + + private static String getStringOrNull(ResultSet rs, String column) { + try { + return rs.getString(column); + } catch (Exception ignored) { + return null; + } + } + + public static void main(String[] args) { + new JsonRpcServer(new Neo4jAgent()).run(); + } +} diff --git a/agents/drivers/neo4j/src/test/java/com/dbx/agent/neo4j/Neo4jAgentTest.java b/agents/drivers/neo4j/src/test/java/com/dbx/agent/neo4j/Neo4jAgentTest.java new file mode 100644 index 0000000..0573ea3 --- /dev/null +++ b/agents/drivers/neo4j/src/test/java/com/dbx/agent/neo4j/Neo4jAgentTest.java @@ -0,0 +1,183 @@ +package com.dbx.agent.neo4j; + +import com.dbx.agent.QueryResult; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class Neo4jAgentTest { + @Test + void executesCypherWritesWithReturnThroughStatementExecute() throws Exception { + Neo4jAgent agent = new Neo4jAgent(); + List calls = new ArrayList<>(); + setConnectionForTest(agent, fakeConnection(calls)); + + QueryResult result = agent.executeQuery("CREATE (n:Person {name: 'Ada'}) RETURN n", null); + + Assertions.assertEquals(Collections.singletonList("n"), result.getColumns()); + Assertions.assertEquals( + Collections.singletonList(Collections.singletonList("(:Person {name: Ada})")), + result.getRows() + ); + Assertions.assertTrue(calls.contains("execute")); + Assertions.assertFalse(calls.contains("executeUpdate")); + } + + @Test + void executesTransactionsThroughStatementExecute() throws Exception { + Neo4jAgent agent = new Neo4jAgent(); + List calls = new ArrayList<>(); + setConnectionForTest(agent, fakeConnection(calls)); + + QueryResult result = agent.executeTransaction( + Arrays.asList( + "MATCH (n:Employee) WHERE elementId(n) = '4:abc:7' SET n.name = 'Grace'", + "CREATE (n:Employee {name: 'Linus'})" + ), + null + ); + + Assertions.assertEquals(0L, result.getAffected_rows()); + Assertions.assertEquals( + Arrays.asList("setAutoCommit:false", "execute", "execute", "commit", "setAutoCommit:true"), + calls + ); + Assertions.assertFalse(calls.contains("executeUpdate")); + } + + private static void setConnectionForTest(Neo4jAgent agent, Connection connection) throws Exception { + java.lang.reflect.Field field = Neo4jAgent.class.getDeclaredField("connection"); + field.setAccessible(true); + field.set(agent, connection); + } + + private static Connection fakeConnection(List calls) { + Statement statement = fakeStatement(calls); + return proxy(Connection.class, (unused, method, args) -> { + switch (method.getName()) { + case "createStatement": + return statement; + case "setAutoCommit": + calls.add("setAutoCommit:" + args[0]); + return null; + case "getAutoCommit": + return true; + case "commit": + calls.add("commit"); + return null; + case "rollback": + calls.add("rollback"); + return null; + case "close": + return null; + case "isClosed": + return false; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static Statement fakeStatement(List calls) { + ResultSet resultSet = fakeResultSet(); + return proxy(Statement.class, (unused, method, args) -> { + switch (method.getName()) { + case "execute": + calls.add("execute"); + return true; + case "executeUpdate": + calls.add("executeUpdate"); + throw new SQLException("syntax error or access rule violation - invalid syntax"); + case "executeQuery": + calls.add("executeQuery"); + return resultSet; + case "getResultSet": + return resultSet; + case "getUpdateCount": + return 0; + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet fakeResultSet() { + int[] index = {-1}; + ResultSetMetaData metadata = fakeMetadata(); + return proxy(ResultSet.class, (unused, method, args) -> { + switch (method.getName()) { + case "next": + index[0] += 1; + return index[0] == 0; + case "getMetaData": + return metadata; + case "getObject": + return "(:Person {name: Ada})"; + case "wasNull": + return false; + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSetMetaData fakeMetadata() { + return proxy(ResultSetMetaData.class, (unused, method, args) -> { + switch (method.getName()) { + case "getColumnCount": + return 1; + case "getColumnLabel": + return "n"; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type, InvocationHandler handler) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) { + return false; + } + if (type == Byte.TYPE) { + return (byte) 0; + } + if (type == Short.TYPE) { + return (short) 0; + } + if (type == Integer.TYPE) { + return 0; + } + if (type == Long.TYPE) { + return 0L; + } + if (type == Float.TYPE) { + return 0F; + } + if (type == Double.TYPE) { + return 0.0D; + } + if (type == Character.TYPE) { + return '\0'; + } + return null; + } +} diff --git a/agents/drivers/oceanbase-oracle/build.gradle b/agents/drivers/oceanbase-oracle/build.gradle new file mode 100644 index 0000000..ef3e6f3 --- /dev/null +++ b/agents/drivers/oceanbase-oracle/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.oceanbase:oceanbase-client:2.4.18' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'OceanBase Oracle Mode', 'Main-Class': 'com.dbx.agent.oceanbaseoracle.OceanBaseOracleAgent') + } +} diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java new file mode 100644 index 0000000..18d4964 --- /dev/null +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java @@ -0,0 +1,623 @@ +package com.dbx.agent.oceanbaseoracle; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.DdlBuilder; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { + private static final long MICROS_PER_SECOND = 1_000_000L; + private static final String COMPATIBLE_OJDBC_VERSION = "compatibleOjdbcVersion"; + private static final String DEFAULT_COMPATIBLE_OJDBC_VERSION = "compatibleOjdbcVersion=8"; + private static final Set SYSTEM_SCHEMAS = Set.of( + "SYS", "SYSTEM", "SYSMAN", "DBSNMP", "SYSBACKUP", "SYSDG", "SYSKM", "OUTLN", + "AUDSYS", "LBACSYS", "DVF", "DVSYS", "APPQOSSYS", "CTXSYS", "MDSYS", "MDDATA", + "ORDSYS", "ORDDATA", "ORDPLUGINS", "XDB", "ANONYMOUS", "DIP", "EXFSYS", + "GSMADMIN_INTERNAL", "GSMCATUSER", "GSMUSER", "OJVMSYS", "OLAPSYS", + "ORACLE_OCM", "SI_INFORMTN_SCHEMA", "WMSYS", "XS$NULL", "DBSFWUSER", + "REMOTE_SCHEDULER_AGENT", "PDBADMIN", "DGPDB_INT", "OPS$ORACLE", + "GGSYS", "FLOWS_FILES", "APEX_PUBLIC_USER", "GSMROOTUSER", "SYSRAC" + ); + + public static final JdbcAgentProfile OCEANBASE_ORACLE_PROFILE = new JdbcAgentProfile( + "com.oceanbase.jdbc.Driver", + "jdbc:oceanbase://{host}:{port}/{database}", + 2883, + false, + SYSTEM_SCHEMAS, + List.of("TABLE", "VIEW", "BASE TABLE") + ) { + @Override + public String schemaSwitchSql(String schema, String quote) { + return "ALTER SESSION SET CURRENT_SCHEMA = " + quote + schema.replace(quote, quote + quote) + quote; + } + }; + + public OceanBaseOracleAgent() { + super(OCEANBASE_ORACLE_PROFILE); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + static String buildUrl(ConnectParams params) { + return appendDefaultCompatibilityOption(OCEANBASE_ORACLE_PROFILE.buildUrl(params)); + } + + @Override + protected void beforeQueryExecution(Connection connection, int timeoutSecs) throws SQLException { + // Connector/J's Statement timeout does not update OceanBase's stricter + // session variable, so synchronize both limits before every execution. + try (var stmt = connection.createStatement()) { + stmt.execute(queryTimeoutSql(timeoutSecs)); + } + } + + static String queryTimeoutSql(int timeoutSecs) { + if (timeoutSecs < 0) { + throw new IllegalArgumentException("Query timeout cannot be negative: " + timeoutSecs); + } + return "ALTER SESSION SET ob_query_timeout = " + timeoutSecs * MICROS_PER_SECOND; + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + for (String schema : querySchemas()) { + result.add(new DatabaseInfo(schema)); + } + return result; + }); + } + + @Override + public List listSchemas() { + return unchecked(this::querySchemas); + } + + @Override + public List listTables(String schema) { + return queryTables(schema, MetadataListConstraints.NONE); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + return queryTables(schema, MetadataListConstraints.orNone(constraints)); + } + + private List queryTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + List objectTypes = oceanBaseTableTypes(constraints); + if (objectTypes.isEmpty()) { + return List.of(); + } + String baseSql = """ + SELECT o.OBJECT_NAME, + CASE o.OBJECT_TYPE WHEN 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS TABLE_TYPE, + c.COMMENTS + FROM ALL_OBJECTS o + LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME + WHERE o.OWNER = ? AND o.OBJECT_TYPE IN (%s) + """.stripIndent().trim(); + MetadataSql query = oceanBaseMetadataSql( + String.format(baseSql, placeholders(objectTypes.size())), + "OBJECT_NAME, TABLE_TYPE, COMMENTS", + "o.OBJECT_NAME", + "ORDER BY OBJECT_NAME", + owner, + objectTypes, + constraints + ); + + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(query.sql)) { + bind(stmt, query.args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1), rs.getString(2), rs.getString(3))); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return queryObjects(schema, MetadataListConstraints.NONE); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + return queryObjects(schema, MetadataListConstraints.orNone(constraints)); + } + + private List queryObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + List objectTypes = oceanBaseObjectTypes(constraints); + if (objectTypes.isEmpty()) { + return List.of(); + } + String baseSql = """ + SELECT OBJECT_NAME, OBJECT_TYPE + FROM ALL_OBJECTS + WHERE OWNER = ? AND OBJECT_TYPE IN (%s) + """.stripIndent().trim(); + MetadataSql query = oceanBaseMetadataSql( + String.format(baseSql, placeholders(objectTypes.size())), + "OBJECT_NAME, OBJECT_TYPE", + "OBJECT_NAME", + """ + ORDER BY CASE OBJECT_TYPE + WHEN 'TABLE' THEN 0 + WHEN 'VIEW' THEN 1 + WHEN 'PROCEDURE' THEN 2 + WHEN 'FUNCTION' THEN 3 + WHEN 'PACKAGE' THEN 4 + WHEN 'SEQUENCE' THEN 5 + ELSE 6 + END, OBJECT_NAME + """.stripIndent().trim(), + owner, + objectTypes, + constraints + ); + + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(query.sql)) { + bind(stmt, query.args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), owner, null)); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + private static MetadataSql oceanBaseMetadataSql( + String baseSql, + String selectList, + String nameColumn, + String orderSql, + String owner, + List objectTypes, + MetadataListConstraints constraints + ) { + List args = new ArrayList<>(); + args.add(owner); + args.addAll(objectTypes); + String sql = baseSql; + if (constraints.hasFilter()) { + sql += " AND UPPER(" + nameColumn + ") LIKE ? ESCAPE '\\'"; + args.add(constraints.fuzzyLikePattern().toUpperCase(Locale.ROOT)); + } + sql += "\n" + orderSql; + if (constraints.hasLimit()) { + // OceanBase Oracle mode is safest with the classic ordered ROWNUM wrapper for paged metadata. + int offset = constraints.getOffset() == null ? 0 : constraints.getOffset(); + sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n" + + sql + + "\n ) DBX_Q\n WHERE ROWNUM <= ?\n)\nWHERE DBX_RN > ?"; + args.add(offset + constraints.getLimit()); + args.add(offset); + } else if (constraints.hasOffset()) { + sql = "SELECT " + selectList + "\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n" + + sql + + "\n ) DBX_Q\n)\nWHERE DBX_RN > ?"; + args.add(constraints.getOffset()); + } + return new MetadataSql(sql, args); + } + + private static List oceanBaseTableTypes(MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return List.of("TABLE", "VIEW"); + } + List result = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + result.add("TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + result.add("VIEW"); + } + return result; + } + + private static List oceanBaseObjectTypes(MetadataListConstraints constraints) { + List supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "SEQUENCE", "SYNONYM"); + if (!constraints.hasObjectTypes()) { + return supported; + } + List result = new ArrayList<>(); + for (String objectType : supported) { + if (constraints.objectTypeAllowed(objectType)) { + result.add(objectType); + } + } + return result; + } + + private static String placeholders(int count) { + return String.join(", ", java.util.Collections.nCopies(count, "?")); + } + + private static void bind(java.sql.PreparedStatement stmt, List args) throws SQLException { + for (int index = 0; index < args.size(); index += 1) { + Object arg = args.get(index); + if (arg instanceof Integer) { + stmt.setInt(index + 1, (Integer) arg); + } else { + stmt.setString(index + 1, String.valueOf(arg)); + } + } + } + + private static final class MetadataSql { + private final String sql; + private final List args; + + private MetadataSql(String sql, List args) { + this.sql = sql; + this.args = args; + } + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + String sql = """ + SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_PRECISION, c.DATA_SCALE, + c.DATA_LENGTH, c.CHAR_LENGTH, c.DATA_DEFAULT, cc.COMMENTS, + CASE WHEN pk.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS IS_PK + FROM ALL_TAB_COLUMNS c + LEFT JOIN ALL_COL_COMMENTS cc + ON cc.OWNER = c.OWNER AND cc.TABLE_NAME = c.TABLE_NAME AND cc.COLUMN_NAME = c.COLUMN_NAME + LEFT JOIN ( + SELECT cols.COLUMN_NAME + FROM ALL_CONS_COLUMNS cols + JOIN ALL_CONSTRAINTS cons + ON cols.CONSTRAINT_NAME = cons.CONSTRAINT_NAME AND cols.OWNER = cons.OWNER + WHERE cons.CONSTRAINT_TYPE = 'P' AND cons.OWNER = ? AND cons.TABLE_NAME = ? + ) pk ON pk.COLUMN_NAME = c.COLUMN_NAME + WHERE c.OWNER = ? AND c.TABLE_NAME = ? + ORDER BY c.COLUMN_ID + """.stripIndent().trim(); + + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + stmt.setString(3, owner); + stmt.setString(4, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + // Oracle-compatible DATA_DEFAULT is LONG-like; read it before other metadata fields. + String defaultValue = rs.getString("DATA_DEFAULT"); + String name = rs.getString("COLUMN_NAME"); + String baseType = rs.getString("DATA_TYPE"); + Integer numPrec = intOrNull(rs, "DATA_PRECISION"); + Integer numScale = intOrNull(rs, "DATA_SCALE"); + Integer dataLen = intOrNull(rs, "DATA_LENGTH"); + Integer charLen = intOrNull(rs, "CHAR_LENGTH"); + result.add(new ColumnInfo( + name, + formatDataType(baseType, numPrec, numScale, dataLen, charLen), + "Y".equalsIgnoreCase(rs.getString("NULLABLE")), + defaultValue, + rs.getInt("IS_PK") == 1, + null, + rs.getString("COMMENTS"), + numPrec, + numScale, + charLen + )); + } + } + } + return result; + }); + } + + @Override + public String getTableDdl(String schema, String table) { + List indexes; + try { + indexes = listIndexes(schema, table); + } catch (RuntimeException e) { + indexes = Collections.emptyList(); + } + + List foreignKeys; + try { + foreignKeys = listForeignKeys(schema, table); + } catch (RuntimeException e) { + foreignKeys = Collections.emptyList(); + } + + return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, false, true); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + String sql = """ + SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.COLUMN_POSITION, i.UNIQUENESS, + c.CONSTRAINT_TYPE, i.INDEX_TYPE + FROM ALL_INDEXES i + JOIN ALL_IND_COLUMNS ic + ON i.INDEX_NAME = ic.INDEX_NAME + AND i.OWNER = ic.INDEX_OWNER + AND i.TABLE_OWNER = ic.TABLE_OWNER + AND i.TABLE_NAME = ic.TABLE_NAME + LEFT JOIN ALL_CONSTRAINTS c + ON i.INDEX_NAME = c.INDEX_NAME + AND i.TABLE_OWNER = c.OWNER + AND i.TABLE_NAME = c.TABLE_NAME + AND c.CONSTRAINT_TYPE = 'P' + WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ? + ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION + """.stripIndent().trim(); + + Map> columnsByIndex = new LinkedHashMap<>(); + Map uniqueByIndex = new LinkedHashMap<>(); + Map primaryByIndex = new LinkedHashMap<>(); + Map typeByIndex = new LinkedHashMap<>(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String indexName = rs.getString("INDEX_NAME"); + columnsByIndex.computeIfAbsent(indexName, ignored -> new ArrayList<>()).add(rs.getString("COLUMN_NAME")); + uniqueByIndex.put(indexName, "UNIQUE".equalsIgnoreCase(rs.getString("UNIQUENESS"))); + primaryByIndex.put(indexName, "P".equalsIgnoreCase(rs.getString("CONSTRAINT_TYPE"))); + typeByIndex.put(indexName, rs.getString("INDEX_TYPE")); + } + } + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : columnsByIndex.entrySet()) { + String name = entry.getKey(); + result.add(new IndexInfo( + name, + entry.getValue(), + Boolean.TRUE.equals(uniqueByIndex.get(name)), + Boolean.TRUE.equals(primaryByIndex.get(name)), + null, + typeByIndex.get(name), + null, + null + )); + } + return result; + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + String sql = """ + SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME + FROM ALL_CONSTRAINTS c + JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER + JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER + JOIN ALL_CONS_COLUMNS rcc + ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME + AND rc.OWNER = rcc.OWNER + AND cc.POSITION = rcc.POSITION + WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = ? AND c.TABLE_NAME = ? + ORDER BY c.CONSTRAINT_NAME, cc.POSITION + """.stripIndent().trim(); + + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString(1), + rs.getString(2), + rs.getString(3), + rs.getString(4) + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + String sql = """ + SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE + FROM ALL_TRIGGERS + WHERE OWNER = ? AND TABLE_NAME = ? + ORDER BY TRIGGER_NAME + """.stripIndent().trim(); + + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo(rs.getString(1), rs.getString(2), rs.getString(3))); + } + } + } + return result; + }); + } + + @Override + public String setSchemaSQL(String schema) { + return "ALTER SESSION SET CURRENT_SCHEMA = " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + private List querySchemas() throws SQLException { + try { + return querySchemaNames(); + } catch (SQLException primaryError) { + String current; + try { + current = currentSchema(); + } catch (SQLException ignored) { + throw primaryError; + } + if (current == null || current.isBlank()) { + throw primaryError; + } + return List.of(current); + } + } + + private List querySchemaNames() throws SQLException { + String placeholders = SYSTEM_SCHEMAS.stream() + .map(schema -> "'" + schema + "'") + .collect(Collectors.joining(",")); + String sql = """ + SELECT username + FROM ALL_USERS + WHERE username IS NOT NULL + AND username NOT IN (%s) + AND username NOT LIKE 'APEX_%%' + AND username NOT LIKE 'FLOWS_%%' + AND username NOT LIKE '%%$%%' + ORDER BY CASE + WHEN username = SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') THEN 0 + WHEN username = SYS_CONTEXT('USERENV', 'SESSION_USER') THEN 1 + ELSE 2 + END, username + """.formatted(placeholders).stripIndent().trim(); + + List result = new ArrayList<>(); + try (var stmt = requireConnection().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String schema = rs.getString(1); + if (schema != null && !schema.isBlank()) { + result.add(schema); + } + } + } + return result; + } + + private String normalizeSchema(String schema) throws SQLException { + String value = schema == null ? "" : schema.trim(); + return value.isEmpty() ? currentSchema() : value; + } + + private String currentSchema() throws SQLException { + try (var stmt = requireConnection().createStatement(); + ResultSet rs = stmt.executeQuery("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL")) { + if (rs.next()) { + String schema = rs.getString(1); + if (schema != null && !schema.isBlank()) { + return schema; + } + } + } + return ""; + } + + private static String appendDefaultCompatibilityOption(String url) { + if (hasQueryKey(url, COMPATIBLE_OJDBC_VERSION)) { + return url; + } + return url + (url.contains("?") ? "&" : "?") + DEFAULT_COMPATIBLE_OJDBC_VERSION; + } + + private static boolean hasQueryKey(String url, String key) { + int queryStart = url.indexOf('?'); + if (queryStart < 0) { + return false; + } + String query = url.substring(queryStart + 1); + int fragmentStart = query.indexOf('#'); + if (fragmentStart >= 0) { + query = query.substring(0, fragmentStart); + } + for (String part : query.split("[&;]")) { + String normalized = part.trim(); + if (normalized.isEmpty()) { + continue; + } + int equals = normalized.indexOf('='); + String paramKey = equals >= 0 ? normalized.substring(0, equals) : normalized; + if (paramKey.trim().equalsIgnoreCase(key)) { + return true; + } + } + return false; + } + + private static String formatDataType(String base, Integer numPrec, Integer numScale, Integer dataLen, Integer charLen) { + if (base == null || base.isBlank()) { + return ""; + } + return switch (base.toUpperCase(Locale.ROOT)) { + case "VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR" -> { + Integer len = charLen == null ? dataLen : charLen; + yield len == null ? base : base + "(" + len + ")"; + } + case "NUMBER" -> { + if (numPrec != null && numScale != null && numScale > 0) { + yield base + "(" + numPrec + "," + numScale + ")"; + } + if (numPrec != null && numPrec > 0) { + yield base + "(" + numPrec + ")"; + } + yield base; + } + case "RAW" -> dataLen == null ? "RAW" : "RAW(" + dataLen + ")"; + default -> base; + }; + } + + private static Integer intOrNull(ResultSet rs, String column) throws SQLException { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + public static void main(String[] args) { + new JsonRpcServer(new OceanBaseOracleAgent()).run(); + } +} diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java new file mode 100644 index 0000000..0530b5c --- /dev/null +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -0,0 +1,352 @@ +package com.dbx.agent.oceanbaseoracle; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +class OceanBaseOracleAgentTest { + @Test + void buildsOceanBaseJdbcUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("oceanbase.example.com"); + params.setPort(0); + params.setDatabase("sys"); + + Assertions.assertEquals( + "jdbc:oceanbase://oceanbase.example.com:2883/sys?compatibleOjdbcVersion=8", + OceanBaseOracleAgent.buildUrl(params) + ); + } + + @Test + void appendsQueryParametersToJdbcUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("oceanbase.example.com"); + params.setPort(2881); + params.setDatabase("sys"); + params.setUrl_params("useSSL=false"); + + Assertions.assertEquals( + "jdbc:oceanbase://oceanbase.example.com:2881/sys?useSSL=false&compatibleOjdbcVersion=8", + OceanBaseOracleAgent.buildUrl(params) + ); + } + + @Test + void keepsExplicitCompatibleOjdbcVersion() { + ConnectParams params = new ConnectParams(); + params.setHost("oceanbase.example.com"); + params.setPort(2881); + params.setDatabase("sys"); + params.setUrl_params("compatibleOjdbcVersion=6&useSSL=false"); + + Assertions.assertEquals( + "jdbc:oceanbase://oceanbase.example.com:2881/sys?compatibleOjdbcVersion=6&useSSL=false", + OceanBaseOracleAgent.buildUrl(params) + ); + } + + @Test + void appendsCompatibleOjdbcVersionToCustomJdbcUrl() { + ConnectParams params = new ConnectParams(); + params.setConnection_string("jdbc:oceanbase://custom-host:2881/sys?useSSL=false"); + + Assertions.assertEquals( + "jdbc:oceanbase://custom-host:2881/sys?useSSL=false&compatibleOjdbcVersion=8", + OceanBaseOracleAgent.buildUrl(params) + ); + } + + @Test + void convertsQueryTimeoutToOceanBaseSessionMicroseconds() { + Assertions.assertEquals( + "ALTER SESSION SET ob_query_timeout = 300000000", + OceanBaseOracleAgent.queryTimeoutSql(300) + ); + Assertions.assertEquals( + "ALTER SESSION SET ob_query_timeout = 0", + OceanBaseOracleAgent.queryTimeoutSql(0) + ); + Assertions.assertEquals( + "ALTER SESSION SET ob_query_timeout = 2147483647000000", + OceanBaseOracleAgent.queryTimeoutSql(Integer.MAX_VALUE) + ); + } + + @Test + void rejectsNegativeQueryTimeout() { + Assertions.assertThrows(IllegalArgumentException.class, () -> OceanBaseOracleAgent.queryTimeoutSql(-1)); + } + + @Test + void synchronizesSessionTimeoutForEveryQueryEntryPoint() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, executionConnection(sql)); + + agent.executeQuery("SELECT 1 FROM DUAL", null, new ExecuteQueryOptions(10, null, 12)); + agent.executeQueryPage("SELECT 2 FROM DUAL", null, new QueryPageOptions(10, null, 10, 13)); + agent.startTableRead("SELECT 3 FROM DUAL", null, new QueryPageOptions(10, null, 10, 14)); + + Assertions.assertEquals(List.of( + "ALTER SESSION SET ob_query_timeout = 12000000", + "SELECT 1 FROM DUAL", + "ALTER SESSION SET ob_query_timeout = 13000000", + "SELECT 2 FROM DUAL", + "ALTER SESSION SET ob_query_timeout = 14000000", + "SELECT 3 FROM DUAL" + ), sql); + } + + @Test + void constrainedListTablesUsesOceanBaseOracleMetadataSql() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"OBJECT_NAME", "TABLE_TYPE", "COMMENTS"}, + new Object[][]{ + {"USER_SETTINGS", "TABLE", null} + } + ))); + + List tables = agent.listTables( + "APP", + new MetadataListConstraints("user", 1, 1, List.of("TABLE")) + ); + + Assertions.assertEquals(1, tables.size()); + Assertions.assertEquals("USER_SETTINGS", tables.get(0).getName()); + Assertions.assertTrue(sql.get(0).contains("ALL_OBJECTS"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("UPPER(o.OBJECT_NAME) LIKE ?"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"), sql.get(0)); + } + + @Test + void constrainedListObjectsUsesOceanBaseOracleMetadataSql() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( + new String[]{"OBJECT_NAME", "OBJECT_TYPE"}, + new Object[][]{ + {"FORMAT_USER", "FUNCTION"} + } + ))); + + List objects = agent.listObjects( + "APP", + new MetadataListConstraints("user", 1, 1, List.of("FUNCTION")) + ); + + Assertions.assertEquals(1, objects.size()); + Assertions.assertEquals("FORMAT_USER", objects.get(0).getName()); + Assertions.assertEquals("FUNCTION", objects.get(0).getObject_type()); + Assertions.assertTrue(sql.get(0).contains("OBJECT_TYPE IN (?)"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"), sql.get(0)); + } + + @Test + void getColumnsIncludesDefaultAndCommentMetadata() { + List sql = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, columnResultSet( + new Object[][]{ + {"DISPLAY_NAME", "VARCHAR2", "Y", null, null, 64, 64, "'anonymous'", "User's display name", 0} + } + ))); + + List columns = agent.getColumns("APP", "USERS"); + + Assertions.assertEquals(1, columns.size()); + ColumnInfo column = columns.get(0); + Assertions.assertEquals("DISPLAY_NAME", column.getName()); + Assertions.assertEquals("VARCHAR2(64)", column.getData_type()); + Assertions.assertTrue(column.getIs_nullable()); + Assertions.assertEquals("'anonymous'", column.getColumn_default()); + Assertions.assertFalse(column.getIs_primary_key()); + Assertions.assertEquals("User's display name", column.getComment()); + Assertions.assertEquals(64, column.getCharacter_maximum_length()); + Assertions.assertTrue(sql.get(0).contains("c.DATA_DEFAULT"), sql.get(0)); + } + + @Test + void tableDdlIncludesDefaultsAndOnlyNonBlankColumnComments() { + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), + resultSet( + new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, + new Object[][]{} + ), + resultSet( + new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new Object[][]{} + ), + columnResultSet(new Object[][]{ + {"CREATED_AT", "TIMESTAMP", "N", null, null, null, null, "SYSDATE", "Created timestamp", 0}, + {"INTERNAL_NOTE", "VARCHAR2", "Y", null, null, 100, 100, null, " ", 0} + }) + )); + + String ddl = agent.getTableDdl("APP", "AUDIT_LOG"); + + Assertions.assertTrue(ddl.contains("\"CREATED_AT\" TIMESTAMP NOT NULL DEFAULT SYSDATE"), ddl); + Assertions.assertTrue( + ddl.contains("COMMENT ON COLUMN \"APP\".\"AUDIT_LOG\".\"CREATED_AT\" IS 'Created timestamp';"), + ddl + ); + Assertions.assertTrue(ddl.contains("\"INTERNAL_NOTE\" VARCHAR2(100)"), ddl); + Assertions.assertFalse(ddl.contains("\"INTERNAL_NOTE\" IS"), ddl); + } + + private static ResultSet columnResultSet(Object[][] rows) { + return resultSet( + new String[]{ + "COLUMN_NAME", + "DATA_TYPE", + "NULLABLE", + "DATA_PRECISION", + "DATA_SCALE", + "DATA_LENGTH", + "CHAR_LENGTH", + "DATA_DEFAULT", + "COMMENTS", + "IS_PK" + }, + rows + ); + } + + private static Connection preparedConnection(List sql, ResultSet... resultSets) { + int[] resultSetIndex = {0}; + PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + int current = Math.min(resultSetIndex[0], resultSets.length - 1); + resultSetIndex[0] += 1; + return resultSets[current]; + } + if ("setString".equals(method.getName()) || "setInt".equals(method.getName()) || "close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection executionConnection(List sql) { + Statement statement = proxy(Statement.class, (method, args) -> { + if ("execute".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return false; + } + if ("getUpdateCount".equals(method.getName())) { + return 0; + } + if ("close".equals(method.getName()) || "setMaxRows".equals(method.getName()) + || "setFetchSize".equals(method.getName()) || "setQueryTimeout".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (method, args) -> { + if ("createStatement".equals(method.getName())) { + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet resultSet(String[] columns, Object[][] rows) { + int[] index = {-1}; + return proxy(ResultSet.class, (method, args) -> { + switch (method.getName()) { + case "next": + index[0] += 1; + return index[0] < rows.length; + case "getString": + Object value = columnValue(columns, rows[index[0]], args[0]); + return value == null ? null : String.valueOf(value); + case "getObject": + return columnValue(columns, rows[index[0]], args[0]); + case "getInt": + Object intValue = columnValue(columns, rows[index[0]], args[0]); + if (intValue instanceof Number) { + return ((Number) intValue).intValue(); + } + if (intValue == null) { + return 0; + } + return Integer.parseInt(String.valueOf(intValue)); + case "close": + return null; + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static Object columnValue(String[] columns, Object[] row, Object key) { + if (key instanceof Number) { + return row[((Number) key).intValue() - 1]; + } + for (int i = 0; i < columns.length; i++) { + if (columns[i].equalsIgnoreCase(String.valueOf(key))) { + return row[i]; + } + } + return null; + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (type == Boolean.TYPE) return false; + if (type == Byte.TYPE) return (byte) 0; + if (type == Short.TYPE) return (short) 0; + if (type == Integer.TYPE) return 0; + if (type == Long.TYPE) return 0L; + if (type == Float.TYPE) return 0f; + if (type == Double.TYPE) return 0d; + if (type == Character.TYPE) return (char) 0; + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/drivers/oracle-go/.gitignore b/agents/drivers/oracle-go/.gitignore new file mode 100644 index 0000000..8aa3c07 --- /dev/null +++ b/agents/drivers/oracle-go/.gitignore @@ -0,0 +1,2 @@ +agent +agent.exe diff --git a/agents/drivers/oracle-go/README.md b/agents/drivers/oracle-go/README.md new file mode 100644 index 0000000..847b78d --- /dev/null +++ b/agents/drivers/oracle-go/README.md @@ -0,0 +1,36 @@ +# DBX Oracle Go Agent + +Experimental native Oracle agent for DBX using `github.com/sijms/go-ora/v2`. + +## Build + +```bash +go build -o agent . +``` + +Cross-compile release builds use pure Go output: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dbx-agent-oracle-linux-x64 . +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dbx-agent-oracle-macos-aarch64 . +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dbx-agent-oracle-windows-x64.exe . +``` + +## Local DBX Test + +Build the binary, then copy it into DBX's installed Oracle driver directory: + +```bash +mkdir -p ~/.dbx/agents/drivers/oracle +cp agent ~/.dbx/agents/drivers/oracle/agent +chmod +x ~/.dbx/agents/drivers/oracle/agent +``` + +DBX prefers `agent` over `agent.jar`, so Oracle connections will use this Go +agent until the file is removed. + +To restore the Java agent: + +```bash +rm ~/.dbx/agents/drivers/oracle/agent +``` diff --git a/agents/drivers/oracle-go/go.mod b/agents/drivers/oracle-go/go.mod new file mode 100644 index 0000000..9d4188c --- /dev/null +++ b/agents/drivers/oracle-go/go.mod @@ -0,0 +1,7 @@ +module github.com/t8y2/dbx/agents/drivers/oracle-go + +go 1.22 + +require github.com/sijms/go-ora/v2 v2.9.0 + +require golang.org/x/text v0.22.0 diff --git a/agents/drivers/oracle-go/go.sum b/agents/drivers/oracle-go/go.sum new file mode 100644 index 0000000..a0fd27b --- /dev/null +++ b/agents/drivers/oracle-go/go.sum @@ -0,0 +1,4 @@ +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/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go new file mode 100644 index 0000000..c3fff0e --- /dev/null +++ b/agents/drivers/oracle-go/main.go @@ -0,0 +1,2902 @@ +package main + +import ( + "bufio" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "regexp" + "sort" + "strconv" + "strings" + "time" + + _ "github.com/sijms/go-ora/v2" + go_ora "github.com/sijms/go-ora/v2" + "github.com/sijms/go-ora/v2/converters" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +const protocolVersion = 1 +const defaultMaxRows = 1000 +const oracleCharsetZHS32GB18030 = 854 + +var ( + oraclePlSQLBlockStartRegexp = regexp.MustCompile(`(?is)^\s*(?:DECLARE|BEGIN|CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:EDITIONABLE|NONEDITIONABLE)\s+)?(?:FUNCTION|PROCEDURE|TRIGGER|PACKAGE(?:\s+BODY)?|TYPE(?:\s+BODY)?))\b`) + oraclePlSQLBlockEndRegexp = regexp.MustCompile(`(?is)\bEND\s*;\s*$`) + oracleNamedPlSQLBlockEndRegexp = regexp.MustCompile(`(?is)\bEND\s+([A-Z0-9_$#]+)\s*;\s*$`) + oracleUnsupportedServerCharsetRegexp = regexp.MustCompile(`server use charset with id: ([0-9]+).*not supported by the driver`) + oracleStringConverters = map[int]converters.IStringConverter{ + oracleCharsetZHS32GB18030: oracleGB18030Converter{}, + } +) + +const oracleListDatabasesSQL = ` +SELECT username AS owner +FROM all_users +WHERE username IS NOT NULL + AND username NOT IN ( + 'SYS','SYSTEM','SYSMAN','DBSNMP','SYSBACKUP','SYSDG','SYSKM','SYSRAC','OUTLN', + 'AUDSYS','LBACSYS','DVF','DVSYS','APPQOSSYS','CTXSYS','MDSYS','MDDATA', + 'ORDSYS','ORDDATA','ORDPLUGINS','XDB','ANONYMOUS','DIP','EXFSYS', + 'GSMADMIN_INTERNAL','GSMCATUSER','GSMROOTUSER','GSMUSER','OJVMSYS','OLAPSYS', + 'ORACLE_OCM','SI_INFORMTN_SCHEMA','WMSYS','XS$NULL','DBSFWUSER', + 'REMOTE_SCHEDULER_AGENT','PDBADMIN','DGPDB_INT','OPS$ORACLE', + 'GGSYS','FLOWS_FILES','APEX_PUBLIC_USER' + ) + AND username NOT LIKE 'APEX_%' + AND username NOT LIKE 'FLOWS_%' + AND username NOT LIKE '%$%' +ORDER BY CASE + WHEN username = SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') THEN 0 + WHEN username = SYS_CONTEXT('USERENV', 'SESSION_USER') THEN 1 + ELSE 2 +END, username` +const oracleListTablesBaseSQL = ` +SELECT OBJECT_NAME, TABLE_TYPE, COMMENTS +FROM ( +SELECT t.TABLE_NAME AS OBJECT_NAME, + 'TABLE' AS TABLE_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM ALL_TABLES t +WHERE t.OWNER = :1 + AND t.NESTED = 'NO' +UNION ALL +SELECT o.OBJECT_NAME, + 'VIEW' AS TABLE_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM ALL_OBJECTS o +WHERE o.OWNER = :2 + AND o.OBJECT_TYPE = 'VIEW' +)` +const oracleListTablesSessionUserBaseSQL = ` +SELECT OBJECT_NAME, TABLE_TYPE, COMMENTS +FROM ( +SELECT t.TABLE_NAME AS OBJECT_NAME, + 'TABLE' AS TABLE_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM USER_TABLES t +WHERE t.NESTED = 'NO' +UNION ALL +SELECT o.OBJECT_NAME, + 'VIEW' AS TABLE_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM USER_OBJECTS o +WHERE o.OBJECT_TYPE = 'VIEW' +)` +const oracleListTablesOrderSQL = `ORDER BY OBJECT_NAME` +const oracleListTablesSQL = oracleListTablesBaseSQL + "\n" + oracleListTablesOrderSQL +const oracleListObjectsBaseSQL = ` +SELECT OBJECT_NAME, OBJECT_TYPE, COMMENTS +FROM ( +SELECT t.TABLE_NAME AS OBJECT_NAME, + 'TABLE' AS OBJECT_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM ALL_TABLES t +WHERE t.OWNER = :1 + AND t.NESTED = 'NO' +UNION ALL +SELECT o.OBJECT_NAME, + CASE o.OBJECT_TYPE WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY' ELSE o.OBJECT_TYPE END AS OBJECT_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM ALL_OBJECTS o +WHERE o.OWNER = :2 + AND o.OBJECT_TYPE IN ('VIEW', 'PROCEDURE', 'FUNCTION', 'PACKAGE', 'PACKAGE BODY') +)` +const oracleListObjectsSessionUserBaseSQL = ` +SELECT OBJECT_NAME, OBJECT_TYPE, COMMENTS +FROM ( +SELECT t.TABLE_NAME AS OBJECT_NAME, + 'TABLE' AS OBJECT_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM USER_TABLES t +WHERE t.NESTED = 'NO' +UNION ALL +SELECT o.OBJECT_NAME, + CASE o.OBJECT_TYPE WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY' ELSE o.OBJECT_TYPE END AS OBJECT_TYPE, + CAST(NULL AS VARCHAR2(4000)) AS COMMENTS +FROM USER_OBJECTS o +WHERE o.OBJECT_TYPE IN ('VIEW', 'PROCEDURE', 'FUNCTION', 'PACKAGE', 'PACKAGE BODY') +)` +const oracleListObjectsOrderSQL = `ORDER BY CASE OBJECT_TYPE + WHEN 'TABLE' THEN 0 + WHEN 'VIEW' THEN 1 + WHEN 'PROCEDURE' THEN 2 + WHEN 'FUNCTION' THEN 3 + WHEN 'PACKAGE' THEN 4 + ELSE 5 +END, OBJECT_NAME` +const oracleListObjectsSQL = oracleListObjectsBaseSQL + "\n" + oracleListObjectsOrderSQL + +type request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type connectParams struct { + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + SysDBA bool `json:"sysdba"` + URLParams string `json:"url_params"` + ConnectionString string `json:"connection_string"` +} + +type queryOptions struct { + SQL string `json:"sql"` + Database string `json:"database"` + Schema string `json:"schema"` + MaxRows int `json:"maxRows"` + FetchSize int `json:"fetchSize"` +} + +type queryResult struct { + Columns []string `json:"columns"` + ColumnTypes []string `json:"column_types"` + Rows [][]any `json:"rows"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMS int64 `json:"execution_time_ms"` + Truncated bool `json:"truncated"` +} + +func (r queryResult) MarshalJSON() ([]byte, error) { + type alias queryResult + value := alias(r) + if value.Columns == nil { + value.Columns = []string{} + } + if value.ColumnTypes == nil { + value.ColumnTypes = []string{} + } + if value.Rows == nil { + value.Rows = [][]any{} + } + return json.Marshal(value) +} + +type queryPageResult struct { + Columns []string `json:"columns"` + ColumnTypes []string `json:"column_types"` + Rows [][]any `json:"rows"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMS int64 `json:"execution_time_ms"` + Truncated bool `json:"truncated"` + SessionID *string `json:"session_id"` + HasMore bool `json:"has_more"` +} + +func (r queryPageResult) MarshalJSON() ([]byte, error) { + type alias queryPageResult + value := alias(r) + if value.Columns == nil { + value.Columns = []string{} + } + if value.ColumnTypes == nil { + value.ColumnTypes = []string{} + } + if value.Rows == nil { + value.Rows = [][]any{} + } + return json.Marshal(value) +} + +type querySession struct { + rows *sql.Rows + columns []string + columnTypes []string + pending []any + remaining int +} + +type oracleColumnMeta struct { + Name string + DataType string +} + +type oracleColumnMetaLoader func(schema, table string) ([]oracleColumnMeta, error) + +type databaseInfo struct { + Name string `json:"name"` +} + +type tableInfo struct { + Name string `json:"name"` + TableType string `json:"table_type"` + Comment *string `json:"comment"` +} + +type metadataListConstraints struct { + Filter string + Limit int + Offset int + ObjectTypes []string +} + +type objectInfo struct { + Name string `json:"name"` + ObjectType string `json:"object_type"` + Schema string `json:"schema"` + Comment *string `json:"comment"` +} + +type columnInfo struct { + Name string `json:"name"` + DataType string `json:"data_type"` + IsNullable bool `json:"is_nullable"` + ColumnDefault *string `json:"column_default"` + IsPrimaryKey bool `json:"is_primary_key"` + Extra *string `json:"extra"` + Comment *string `json:"comment"` + NumericPrecision *int `json:"numeric_precision"` + NumericScale *int `json:"numeric_scale"` + CharacterMaximumLength *int `json:"character_maximum_length"` +} + +type indexInfo struct { + Name string `json:"name"` + Columns []string `json:"columns"` + IsUnique bool `json:"is_unique"` + IsPrimary bool `json:"is_primary"` + Filter *string `json:"filter"` + IndexType *string `json:"index_type"` + IncludedColumns []string `json:"included_columns"` + Comment *string `json:"comment"` +} + +func (i indexInfo) MarshalJSON() ([]byte, error) { + type alias indexInfo + value := alias(i) + if value.Columns == nil { + value.Columns = []string{} + } + if value.IncludedColumns == nil { + value.IncludedColumns = []string{} + } + return json.Marshal(value) +} + +type foreignKeyInfo struct { + Name string `json:"name"` + Column string `json:"column"` + RefTable string `json:"ref_table"` + RefColumn string `json:"ref_column"` +} + +type triggerInfo struct { + Name string `json:"name"` + Event string `json:"event"` + Timing string `json:"timing"` +} + +type server struct { + db *sql.DB + params connectParams + sessions map[string]*querySession + tableReadSessions map[string]*querySession + nextSessionID int64 + nextTableReadSessionID int64 +} + +func main() { + s := newServer() + encoder := json.NewEncoder(os.Stdout) + fmt.Fprintln(os.Stdout, `{"ready":true}`) + + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + resp, shutdown := s.handleLine(line) + if err := encoder.Encode(resp); err != nil { + fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err) + return + } + if shutdown { + return + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { + fmt.Fprintf(os.Stderr, "failed to read stdin: %v\n", err) + } +} + +func newServer() *server { + return &server{sessions: map[string]*querySession{}, tableReadSessions: map[string]*querySession{}} +} + +func (s *server) handleLine(line string) (response, bool) { + var req request + if err := json.Unmarshal([]byte(line), &req); err != nil { + return errorResponse(nil, err), false + } + if len(req.ID) == 0 { + req.ID = json.RawMessage("1") + } + result, shutdown, err := s.dispatch(req.Method, req.Params) + if err != nil { + return errorResponse(req.ID, err), false + } + return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown +} + +func (s *server) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) { + switch method { + case "handshake": + return map[string]any{ + "protocolVersion": protocolVersion, + "agentProtocolVersion": protocolVersion, + "capabilities": []string{"connect", "test_connection", "metadata", "query", "ddl"}, + }, false, nil + case "connect": + var cp connectParams + if err := decodeParams(params, &cp); err != nil { + return nil, false, err + } + return map[string]bool{"ok": true}, false, s.connect(cp) + case "test_connection": + var cp connectParams + if err := decodeParams(params, &cp); err != nil { + return nil, false, err + } + db, err := openAndPingDB(cp, 5*time.Second) + if err != nil { + return nil, false, err + } + defer db.Close() + return map[string]bool{"ok": true}, false, nil + case "list_databases": + result, err := s.listDatabases() + return result, false, err + case "list_schemas": + result, err := s.listSchemas(stringSliceParam(params, "visible_schemas")) + return result, false, err + case "list_tables": + schema := stringParam(params, "schema") + result, err := s.listTables(schema, metadataListConstraintsFromParams(params)) + return result, false, err + case "list_objects": + schema := stringParam(params, "schema") + result, err := s.listObjects(schema, metadataListConstraintsFromParams(params)) + return result, false, err + case "get_columns": + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.getColumns(schema, table) + return result, false, err + case "get_object_source": + schema := stringParam(params, "schema") + name := stringParam(params, "name") + objectType := stringParam(params, "object_type") + source, err := s.getObjectSource(schema, name, objectType) + return source, false, err + case "get_table_ddl": + schema := stringParam(params, "schema") + table := stringParam(params, "table") + objectType := stringParam(params, "object_type") + ddl, err := s.getTableDDL(schema, table, objectType) + return ddl, false, err + case "execute_query": + var opts queryOptions + if err := decodeParams(params, &opts); err != nil { + return nil, false, err + } + result, err := s.executeQuery(opts) + return result, false, err + case "execute_query_page": + var opts queryOptions + if err := decodeParams(params, &opts); err != nil { + return nil, false, err + } + result, err := s.executeQueryPage(opts, intParam(params, "pageSize")) + return result, false, err + case "fetch_query_page": + result, err := s.fetchQueryPage(stringParam(params, "sessionId"), intParam(params, "pageSize")) + return result, false, err + case "close_query_session": + return s.closeQuerySession(stringParam(params, "sessionId")), false, nil + case "start_table_read": + var opts queryOptions + if err := decodeParams(params, &opts); err != nil { + return nil, false, err + } + result, err := s.startTableRead(opts, intParam(params, "pageSize")) + return result, false, err + case "fetch_table_read_page": + result, err := s.fetchTableReadPage(stringParam(params, "sessionId"), intParam(params, "pageSize")) + return result, false, err + case "close_table_read_session": + return s.closeTableReadSession(stringParam(params, "sessionId")), false, nil + case "list_indexes": + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listIndexes(schema, table) + return result, false, err + case "list_foreign_keys": + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listForeignKeys(schema, table) + return result, false, err + case "list_triggers": + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listTriggers(schema, table) + return result, false, err + case "get_explain_info": + sqlText := stringParam(params, "sql") + plan, err := s.getExplainInfo( + sqlText, + stringParam(params, "database"), + stringParam(params, "schema"), + intParam(params, "timeoutSecs"), + ) + return map[string]any{"plan": plan, "has_actual_stats": false}, false, err + case "execute_transaction": + result, err := s.executeTransaction(params) + return result, false, err + case "disconnect": + return map[string]bool{"ok": true}, false, s.disconnect() + case "shutdown": + _ = s.disconnect() + return map[string]bool{"ok": true}, true, nil + default: + return nil, false, fmt.Errorf("unknown method: %s", method) + } +} + +func (s *server) connect(params connectParams) error { + _ = s.disconnect() + db, err := openAndPingDB(params, 15*time.Second) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, err := db.ExecContext(ctx, "ALTER SESSION SET NLS_LANGUAGE='AMERICAN'"); err != nil { + db.Close() + return err + } + s.db = db + s.params = params + return nil +} + +func (s *server) disconnect() error { + s.closeAllQuerySessions() + if s.db == nil { + return nil + } + err := s.db.Close() + s.db = nil + return err +} + +func openDB(params connectParams) (*sql.DB, error) { + return openDBWithStringConverter(params, nil) +} + +func openDBWithStringConverter(params connectParams, stringConverter converters.IStringConverter) (*sql.DB, error) { + dsn := buildDSN(params) + var db *sql.DB + if stringConverter == nil { + var err error + db, err = sql.Open("oracle", dsn) + if err != nil { + return nil, err + } + } else { + connector := go_ora.NewConnector(dsn) + go_ora.SetStringConverter(connector, stringConverter, nil) + db = sql.OpenDB(connector) + } + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(30 * time.Minute) + return db, nil +} + +func openAndPingDB(params connectParams, timeout time.Duration) (*sql.DB, error) { + db, err := openDB(params) + if err != nil { + return nil, err + } + if err := pingDB(db, timeout); err != nil { + db.Close() + stringConverter, ok := oracleStringConverterForUnsupportedCharsetError(err) + if !ok { + return nil, err + } + // Retry only charsets with explicit converters. Guessing a converter + // can make the connection succeed while silently corrupting text. + db, err = openDBWithStringConverter(params, stringConverter) + if err != nil { + return nil, err + } + if retryErr := pingDB(db, timeout); retryErr != nil { + db.Close() + return nil, retryErr + } + } + return db, nil +} + +func pingDB(db *sql.DB, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return db.PingContext(ctx) +} + +func oracleStringConverterForUnsupportedCharsetError(err error) (converters.IStringConverter, bool) { + charsetID, ok := unsupportedOracleServerCharsetID(err) + if !ok { + return nil, false + } + stringConverter, ok := oracleStringConverters[charsetID] + return stringConverter, ok +} + +func unsupportedOracleServerCharsetID(err error) (int, bool) { + if err == nil { + return 0, false + } + match := oracleUnsupportedServerCharsetRegexp.FindStringSubmatch(err.Error()) + if len(match) != 2 { + return 0, false + } + charsetID, parseErr := strconv.Atoi(match[1]) + if parseErr != nil { + return 0, false + } + return charsetID, true +} + +type oracleGB18030Converter struct{} + +func (oracleGB18030Converter) Encode(input string) []byte { + output, _, err := transform.String(simplifiedchinese.GB18030.NewEncoder(), input) + if err != nil { + return []byte(input) + } + return []byte(output) +} + +func (oracleGB18030Converter) Decode(input []byte) string { + output, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), input) + if err != nil { + return string(input) + } + return string(output) +} + +func (oracleGB18030Converter) GetLangID() int { + return oracleCharsetZHS32GB18030 +} + +func (oracleGB18030Converter) Clone() converters.IStringConverter { + return oracleGB18030Converter{} +} + +func buildDSN(params connectParams) string { + connectionString := strings.TrimSpace(params.ConnectionString) + if strings.HasPrefix(strings.ToLower(connectionString), "oracle://") { + return connectionString + } + username := params.Username + options := parseURLParams(params.URLParams) + if params.SysDBA { + options["AUTH TYPE"] = "SYSDBA" + } + + if jdbc := parseOracleJDBCURL(connectionString); jdbc.Kind != "" { + if jdbc.Descriptor != "" { + return buildGoOraJDBC(username, params.Password, jdbc.Descriptor, options) + } + host := jdbc.Host + port := jdbc.Port + if port == 0 { + port = 1521 + } + if jdbc.Kind == "sid" { + options["SID"] = jdbc.Database + return buildGoOraURL(host, port, "", username, params.Password, options) + } + return buildGoOraURL(host, port, jdbc.Database, username, params.Password, options) + } + + service := strings.TrimSpace(params.Database) + if strings.HasPrefix(strings.ToUpper(service), "SYSDBA:") { + service = strings.TrimSpace(service[len("SYSDBA:"):]) + } + port := params.Port + if port == 0 { + port = 1521 + } + return buildGoOraURL(params.Host, port, service, username, params.Password, options) +} + +func buildGoOraJDBC(user, password, connStr string, options map[string]string) string { + if options == nil { + options = make(map[string]string) + } + options["connStr"] = connStr + return buildGoOraURL("", 0, "", user, password, options) +} + +func buildGoOraURL(server string, port int, service, user, password string, options map[string]string) string { + // go-ora v2.9.0 uses path escaping for user/password, leaving ':' unescaped. + // Userinfo escaping keeps bastion usernames such as 9008888:reader intact + // without changing their authentication semantics. + ret := fmt.Sprintf( + "oracle://%s@%s/%s", + url.UserPassword(user, password).String(), + net.JoinHostPort(server, strconv.Itoa(port)), + url.PathEscape(service), + ) + if options != nil { + ret += "?" + for key, val := range options { + val = strings.TrimSpace(val) + for _, temp := range strings.Split(val, ",") { + temp = strings.TrimSpace(temp) + if strings.ToUpper(key) == "SERVER" { + ret += fmt.Sprintf("%s=%s&", key, temp) + } else { + ret += fmt.Sprintf("%s=%s&", key, url.QueryEscape(temp)) + } + } + } + ret = strings.TrimRight(ret, "&") + } + return ret +} + +type jdbcURLInfo struct { + Kind string + Host string + Port int + Database string + Descriptor string +} + +var ( + oracleJDBCServiceRegexp = regexp.MustCompile(`(?i)^jdbc:oracle:thin:@//([^/:]+):([0-9]+)/([^?]+)`) + oracleJDBCSIDRegexp = regexp.MustCompile(`(?i)^jdbc:oracle:thin:@([^/:]+):([0-9]+):([^?]+)`) + oracleJDBCLegacyRegexp = regexp.MustCompile(`(?i)^jdbc:oracle:thin:@([^/:]+):([0-9]+)/([^?]+)`) +) + +func parseOracleJDBCURL(value string) jdbcURLInfo { + value = strings.TrimSpace(value) + lower := strings.ToLower(value) + if !strings.HasPrefix(lower, "jdbc:oracle:thin:@") { + return jdbcURLInfo{} + } + descriptor := strings.TrimSpace(value[len("jdbc:oracle:thin:@"):]) + if strings.HasPrefix(descriptor, "(") { + return jdbcURLInfo{Kind: "descriptor", Descriptor: descriptor} + } + if match := oracleJDBCServiceRegexp.FindStringSubmatch(value); 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 { + return jdbcURLInfo{Kind: "sid", Host: match[1], Port: parsePort(match[2]), Database: match[3]} + } + if match := oracleJDBCLegacyRegexp.FindStringSubmatch(value); len(match) == 4 { + return jdbcURLInfo{Kind: "service", Host: match[1], Port: parsePort(match[2]), Database: match[3]} + } + return jdbcURLInfo{} +} + +func parsePort(value string) int { + port, _ := strconv.Atoi(value) + return port +} + +func (s *server) requireDB() (*sql.DB, error) { + if s.db == nil { + return nil, errors.New("agent is not connected") + } + return s.db, nil +} + +func (s *server) listDatabases() ([]databaseInfo, error) { + rows, err := s.queryRows(oracleListDatabasesSQL, nil) + if err != nil { + if isOraclePGALimitError(err) { + return s.currentSchemaDatabase() + } + return nil, err + } + defer rows.Close() + var result []databaseInfo + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + result = append(result, databaseInfo{Name: name}) + } + if err := rows.Err(); err != nil { + if isOraclePGALimitError(err) { + return s.currentSchemaDatabase() + } + return nil, err + } + return emptyIfNil(result), nil +} + +func isOraclePGALimitError(err error) bool { + return err != nil && strings.Contains(strings.ToUpper(err.Error()), "ORA-04036") +} + +func (s *server) currentSchemaDatabase() ([]databaseInfo, error) { + schema, err := s.currentSchema() + if err != nil { + return nil, err + } + if strings.TrimSpace(schema) == "" { + return []databaseInfo{}, nil + } + return []databaseInfo{{Name: schema}}, nil +} + +func (s *server) listSchemas(visibleSchemas []string) ([]string, error) { + if visibleSchemas != nil && len(visibleSchemas) == 0 { + return []string{}, nil + } + databases, err := s.listDatabasesFiltered(visibleSchemas) + if err != nil { + return nil, err + } + result := make([]string, 0, len(databases)) + for _, database := range databases { + result = append(result, database.Name) + } + return emptyIfNil(result), nil +} + +func (s *server) listDatabasesFiltered(visibleSchemas []string) ([]databaseInfo, error) { + if visibleSchemas == nil { + return s.listDatabases() + } + sqlText, args := oracleListDatabasesSQLWithVisibleSchemas(visibleSchemas) + rows, err := s.queryRows(sqlText, args) + if err != nil { + if isOraclePGALimitError(err) { + return s.currentSchemaDatabase() + } + return nil, err + } + defer rows.Close() + var result []databaseInfo + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + result = append(result, databaseInfo{Name: name}) + } + if err := rows.Err(); err != nil { + if isOraclePGALimitError(err) { + return s.currentSchemaDatabase() + } + return nil, err + } + return emptyIfNil(result), nil +} + +func oracleListDatabasesSQLWithVisibleSchemas(visibleSchemas []string) (string, []any) { + if len(visibleSchemas) == 0 { + return oracleListDatabasesSQL, nil + } + placeholders := make([]string, 0, len(visibleSchemas)) + args := make([]any, 0, len(visibleSchemas)) + for i, schema := range visibleSchemas { + placeholders = append(placeholders, fmt.Sprintf(":%d", i+1)) + args = append(args, schema) + } + sqlText := strings.Replace( + oracleListDatabasesSQL, + "\nORDER BY CASE", + "\n AND username IN ("+strings.Join(placeholders, ",")+")\nORDER BY CASE", + 1, + ) + return sqlText, args +} + +func (s *server) currentSchema() (string, error) { + db, err := s.requireDB() + if err != nil { + return "", err + } + var schema string + if err := db.QueryRow("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL").Scan(&schema); err != nil { + return "", err + } + return strings.ToUpper(schema), nil +} + +func (s *server) normalizeSchema(schema string) (string, error) { + schema = strings.TrimSpace(schema) + if schema == "" { + return s.currentSchema() + } + return strings.ToUpper(schema), nil +} + +func (s *server) sessionUser() (string, error) { + db, err := s.requireDB() + if err != nil { + return "", err + } + var username string + if err := db.QueryRow("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL").Scan(&username); err != nil { + return "", err + } + return strings.ToUpper(username), nil +} + +func (s *server) schemaIsSessionUser(schema string) bool { + username, err := s.sessionUser() + return err == nil && strings.EqualFold(schema, username) +} + +type oracleMetadataListQuery struct { + SQL string + Args []any +} + +func metadataListConstraintsFromParams(params map[string]json.RawMessage) metadataListConstraints { + objectTypes := stringSliceParam(params, "object_types") + if len(objectTypes) == 0 { + objectTypes = stringSliceParam(params, "objectTypes") + } + limit := intParam(params, "limit") + offset := intParam(params, "offset") + if limit < 0 { + limit = 0 + } + if offset < 0 { + offset = 0 + } + return metadataListConstraints{ + Filter: stringParam(params, "filter"), + Limit: limit, + Offset: offset, + ObjectTypes: objectTypes, + } +} + +func oracleListTablesQuery(schema string, constraints metadataListConstraints) oracleMetadataListQuery { + return oracleConstrainedMetadataListQuery( + oracleListTablesBaseSQL, + "OBJECT_NAME, TABLE_TYPE, COMMENTS", + "TABLE_TYPE", + oracleListTablesOrderSQL, + []any{schema, schema}, + constraints, + ) +} + +func oracleListSessionUserTablesQuery(constraints metadataListConstraints) oracleMetadataListQuery { + return oracleConstrainedMetadataListQuery( + oracleListTablesSessionUserBaseSQL, + "OBJECT_NAME, TABLE_TYPE, COMMENTS", + "TABLE_TYPE", + oracleListTablesOrderSQL, + nil, + constraints, + ) +} + +func oracleListObjectsQuery(schema string, constraints metadataListConstraints) oracleMetadataListQuery { + return oracleConstrainedMetadataListQuery( + oracleListObjectsBaseSQL, + "OBJECT_NAME, OBJECT_TYPE, COMMENTS", + "OBJECT_TYPE", + oracleListObjectsOrderSQL, + []any{schema, schema}, + constraints, + ) +} + +func oracleListSessionUserObjectsQuery(constraints metadataListConstraints) oracleMetadataListQuery { + return oracleConstrainedMetadataListQuery( + oracleListObjectsSessionUserBaseSQL, + "OBJECT_NAME, OBJECT_TYPE, COMMENTS", + "OBJECT_TYPE", + oracleListObjectsOrderSQL, + nil, + constraints, + ) +} + +func oracleConstrainedMetadataListQuery(baseSQL, selectList, typeColumn, orderSQL string, baseArgs []any, constraints metadataListConstraints) oracleMetadataListQuery { + args := append([]any{}, baseArgs...) + where := make([]string, 0, 2) + if filter := strings.TrimSpace(constraints.Filter); filter != "" { + args = append(args, strings.ToUpper(oracleFuzzyLikePattern(filter))) + where = append(where, fmt.Sprintf("UPPER(OBJECT_NAME) LIKE :%d ESCAPE '\\'", len(args))) + } + if objectTypes := normalizedMetadataObjectTypes(constraints.ObjectTypes); len(objectTypes) > 0 { + placeholders := make([]string, 0, len(objectTypes)) + for _, objectType := range objectTypes { + args = append(args, objectType) + placeholders = append(placeholders, fmt.Sprintf(":%d", len(args))) + } + where = append(where, fmt.Sprintf("%s IN (%s)", typeColumn, strings.Join(placeholders, ","))) + } + + sqlText := fmt.Sprintf("SELECT %s\nFROM (\n%s\n)", selectList, baseSQL) + if len(where) > 0 { + sqlText += "\nWHERE " + strings.Join(where, " AND ") + } + sqlText += "\n" + orderSQL + + if constraints.Limit > 0 { + args = append(args, constraints.Offset+constraints.Limit) + maxRowParam := len(args) + args = append(args, constraints.Offset) + offsetParam := len(args) + sqlText = fmt.Sprintf( + "SELECT %s\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n%s\n ) DBX_Q\n WHERE ROWNUM <= :%d\n)\nWHERE DBX_RN > :%d", + selectList, + sqlText, + maxRowParam, + offsetParam, + ) + } else if constraints.Offset > 0 { + args = append(args, constraints.Offset) + offsetParam := len(args) + sqlText = fmt.Sprintf( + "SELECT %s\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n%s\n ) DBX_Q\n)\nWHERE DBX_RN > :%d", + selectList, + sqlText, + offsetParam, + ) + } + + return oracleMetadataListQuery{SQL: sqlText, Args: args} +} + +func normalizedMetadataObjectTypes(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.ToUpper(strings.TrimSpace(value)) + normalized = strings.ReplaceAll(normalized, "-", "_") + normalized = strings.ReplaceAll(normalized, " ", "_") + if normalized == "" || seen[normalized] { + continue + } + seen[normalized] = true + result = append(result, normalized) + } + sort.Strings(result) + return result +} + +func oracleFuzzyLikePattern(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "%%" + } + var builder strings.Builder + builder.Grow(len(value)*2 + 2) + builder.WriteByte('%') + for _, ch := range value { + switch ch { + case '\\', '%', '_': + builder.WriteByte('\\') + } + builder.WriteRune(ch) + builder.WriteByte('%') + } + return builder.String() +} + +func (s *server) listTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + query := oracleListTablesQuery(schema, constraints) + if s.schemaIsSessionUser(schema) { + query = oracleListSessionUserTablesQuery(constraints) + } + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + if isOraclePGALimitError(err) { + return []tableInfo{}, nil + } + return nil, err + } + defer rows.Close() + var result []tableInfo + for rows.Next() { + var item tableInfo + if err := rows.Scan(&item.Name, &item.TableType, &item.Comment); err != nil { + return nil, err + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + query := oracleListObjectsQuery(schema, constraints) + if s.schemaIsSessionUser(schema) { + query = oracleListSessionUserObjectsQuery(constraints) + } + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + if isOraclePGALimitError(err) { + return []objectInfo{}, nil + } + return nil, err + } + defer rows.Close() + var result []objectInfo + for rows.Next() { + var item objectInfo + item.Schema = schema + if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment); err != nil { + return nil, err + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) getColumns(schema, table string) ([]columnInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT c.COLUMN_NAME, + c.DATA_TYPE, + c.NULLABLE, + c.DATA_DEFAULT, + CASE WHEN pk.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS IS_PRIMARY_KEY, + cc.COMMENTS, + c.DATA_PRECISION, + c.DATA_SCALE, + c.CHAR_LENGTH +FROM ALL_TAB_COLUMNS c +LEFT JOIN ( + SELECT acc.OWNER, acc.TABLE_NAME, acc.COLUMN_NAME + FROM ALL_CONSTRAINTS ac + JOIN ALL_CONS_COLUMNS acc ON acc.OWNER = ac.OWNER AND acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME + WHERE ac.CONSTRAINT_TYPE = 'P' +) pk ON pk.OWNER = c.OWNER AND pk.TABLE_NAME = c.TABLE_NAME AND pk.COLUMN_NAME = c.COLUMN_NAME +LEFT JOIN ALL_COL_COMMENTS cc ON cc.OWNER = c.OWNER AND cc.TABLE_NAME = c.TABLE_NAME AND cc.COLUMN_NAME = c.COLUMN_NAME +WHERE c.OWNER = :1 AND c.TABLE_NAME = :2 +ORDER BY c.COLUMN_ID`, []any{schema, table}) + if err != nil { + return nil, err + } + defer rows.Close() + var result []columnInfo + for rows.Next() { + var item columnInfo + var nullable string + var primary int + if err := rows.Scan( + &item.Name, + &item.DataType, + &nullable, + &item.ColumnDefault, + &primary, + &item.Comment, + &item.NumericPrecision, + &item.NumericScale, + &item.CharacterMaximumLength, + ); err != nil { + return nil, err + } + item.IsNullable = nullable == "Y" + item.IsPrimaryKey = primary != 0 + item.DataType = oracleColumnTypeDDL(item) + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) loadOracleColumnMeta(schema, table string) ([]oracleColumnMeta, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT COLUMN_NAME, DATA_TYPE +FROM ALL_TAB_COLUMNS +WHERE OWNER = :1 AND TABLE_NAME = :2 +ORDER BY COLUMN_ID`, []any{schema, table}) + if err != nil { + return nil, err + } + defer rows.Close() + var result []oracleColumnMeta + for rows.Next() { + var item oracleColumnMeta + if err := rows.Scan(&item.Name, &item.DataType); err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *server) listIndexes(schema, table string) ([]indexInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT i.INDEX_NAME, + ic.COLUMN_NAME, + i.UNIQUENESS, + CASE WHEN pk.CONSTRAINT_NAME IS NULL THEN 0 ELSE 1 END AS IS_PRIMARY, + i.INDEX_TYPE, + ic.COLUMN_POSITION +FROM ALL_INDEXES i +JOIN ALL_IND_COLUMNS ic ON ic.INDEX_OWNER = i.OWNER AND ic.INDEX_NAME = i.INDEX_NAME +LEFT JOIN ALL_CONSTRAINTS pk ON pk.OWNER = i.TABLE_OWNER + AND pk.TABLE_NAME = i.TABLE_NAME + AND pk.CONSTRAINT_TYPE = 'P' + AND pk.INDEX_NAME = i.INDEX_NAME +WHERE i.TABLE_OWNER = :1 AND i.TABLE_NAME = :2 +ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION`, []any{schema, table}) + if err != nil { + return nil, err + } + defer rows.Close() + + byName := map[string]*indexInfo{} + order := []string{} + for rows.Next() { + var name, column, uniqueness, indexType string + var primary int + var position int + if err := rows.Scan(&name, &column, &uniqueness, &primary, &indexType, &position); err != nil { + return nil, err + } + item := byName[name] + if item == nil { + item = &indexInfo{ + Name: name, + Columns: []string{}, + IsUnique: uniqueness == "UNIQUE", + IsPrimary: primary != 0, + IndexType: &indexType, + IncludedColumns: []string{}, + } + byName[name] = item + order = append(order, name) + } + item.Columns = append(item.Columns, column) + } + if err := rows.Err(); err != nil { + return nil, err + } + result := make([]indexInfo, 0, len(order)) + for _, name := range order { + result = append(result, *byName[name]) + } + return emptyIfNil(result), nil +} + +func (s *server) listForeignKeys(schema, table string) ([]foreignKeyInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT ac.CONSTRAINT_NAME, + acc.COLUMN_NAME, + rcc.TABLE_NAME AS REF_TABLE, + rcc.COLUMN_NAME AS REF_COLUMN +FROM ALL_CONSTRAINTS ac +JOIN ALL_CONS_COLUMNS acc ON acc.OWNER = ac.OWNER AND acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME +JOIN ALL_CONS_COLUMNS rcc ON rcc.OWNER = ac.R_OWNER AND rcc.CONSTRAINT_NAME = ac.R_CONSTRAINT_NAME + AND rcc.POSITION = acc.POSITION +WHERE ac.OWNER = :1 + AND ac.TABLE_NAME = :2 + AND ac.CONSTRAINT_TYPE = 'R' +ORDER BY ac.CONSTRAINT_NAME, acc.POSITION`, []any{schema, table}) + if err != nil { + return nil, err + } + defer rows.Close() + var result []foreignKeyInfo + for rows.Next() { + var item foreignKeyInfo + if err := rows.Scan(&item.Name, &item.Column, &item.RefTable, &item.RefColumn); err != nil { + return nil, err + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE +FROM ALL_TRIGGERS +WHERE OWNER = :1 AND TABLE_NAME = :2 +ORDER BY TRIGGER_NAME`, []any{schema, table}) + if err != nil { + return nil, err + } + defer rows.Close() + var result []triggerInfo + for rows.Next() { + var item triggerInfo + if err := rows.Scan(&item.Name, &item.Event, &item.Timing); err != nil { + return nil, err + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) { + var err error + schema, err = s.normalizeSchema(schema) + if err != nil { + return nil, err + } + upperType := strings.ToUpper(objectType) + if upperType == "VIEW" { + // ALL_VIEWS.TEXT for views — ALL_SOURCE doesn't contain views, and + // DBMS_METADATA.GET_DDL fails on XE editions. + var source string + err = s.db.QueryRow( + "SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2", + schema, strings.ToUpper(name), + ).Scan(&source) + if errors.Is(err, sql.ErrNoRows) { + return map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": ""}, nil + } + if err != nil { + return nil, err + } + return map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": source}, nil + } + + rows, err := s.queryRows(` +SELECT TEXT +FROM ALL_SOURCE +WHERE OWNER = :1 AND NAME = :2 AND TYPE = :3 +ORDER BY LINE`, []any{schema, strings.ToUpper(name), upperType}) + if err != nil { + return nil, err + } + defer rows.Close() + var builder strings.Builder + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + return nil, err + } + builder.WriteString(line) + } + return map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": builder.String()}, rows.Err() +} + +func (s *server) getTableDDL(schema, table, objectType string) (string, error) { + var err error + schema, err = s.normalizeSchema(schema) + if err != nil { + return "", err + } + db, err := s.requireDB() + if err != nil { + return "", err + } + objectType, err = s.resolveDDLObjectType(schema, table, objectType) + if err != nil { + return "", err + } + if objectType == "VIEW" { + return s.buildViewDDL(schema, table) + } + var ddl string + err = db.QueryRow("SELECT DBMS_METADATA.GET_DDL(:1, :2, :3) FROM DUAL", objectType, strings.ToUpper(table), schema).Scan(&ddl) + if err == nil && strings.TrimSpace(ddl) != "" { + return ddl, nil + } + if objectType == "TABLE" { + return s.buildTableDDL(schema, table) + } + return "", err +} + +func (s *server) resolveDDLObjectType(schema, name, requested string) (string, error) { + objectType := normalizeDDLObjectType(requested) + if objectType != "" { + return objectType, nil + } + db, err := s.requireDB() + if err != nil { + return "", err + } + err = db.QueryRow(` +SELECT OBJECT_TYPE +FROM ( + SELECT OBJECT_TYPE + FROM ALL_OBJECTS + WHERE OWNER = :1 + AND OBJECT_NAME = :2 + AND OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW') + ORDER BY CASE OBJECT_TYPE WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 ELSE 2 END +) +WHERE ROWNUM = 1`, schema, strings.ToUpper(name)).Scan(&objectType) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("object not found: %s.%s", schema, name) + } + if err != nil { + return "", err + } + return normalizeDDLObjectType(objectType), nil +} + +func normalizeDDLObjectType(value string) string { + switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), " ", "_")) { + case "TABLE": + return "TABLE" + case "VIEW": + return "VIEW" + case "MATERIALIZED_VIEW": + return "MATERIALIZED_VIEW" + default: + return "" + } +} + +func (s *server) buildViewDDL(schema, name string) (string, error) { + db, err := s.requireDB() + if err != nil { + return "", err + } + var source string + err = db.QueryRow( + "SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2", + schema, strings.ToUpper(name), + ).Scan(&source) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("view not found: %s.%s", schema, name) + } + if err != nil { + return "", err + } + return fmt.Sprintf("CREATE OR REPLACE VIEW %s.%s AS\n%s", quoteIdentifier(schema), quoteIdentifier(name), strings.TrimSpace(source)), nil +} + +func (s *server) buildTableDDL(schema, table string) (string, error) { + columns, err := s.getColumns(schema, table) + if err != nil { + return "", err + } + if len(columns) == 0 { + return "", fmt.Errorf("table not found: %s.%s", schema, table) + } + var builder strings.Builder + builder.WriteString("CREATE TABLE ") + builder.WriteString(quoteIdentifier(schema)) + builder.WriteByte('.') + builder.WriteString(quoteIdentifier(table)) + builder.WriteString(" (\n") + for i, column := range columns { + if i > 0 { + builder.WriteString(",\n") + } + builder.WriteString(" ") + builder.WriteString(quoteIdentifier(column.Name)) + builder.WriteByte(' ') + builder.WriteString(oracleColumnTypeDDL(column)) + if column.ColumnDefault != nil && strings.TrimSpace(*column.ColumnDefault) != "" { + builder.WriteString(" DEFAULT ") + builder.WriteString(strings.TrimSpace(*column.ColumnDefault)) + } + if !column.IsNullable { + builder.WriteString(" NOT NULL") + } + } + primary := make([]string, 0) + for _, column := range columns { + if column.IsPrimaryKey { + primary = append(primary, quoteIdentifier(column.Name)) + } + } + if len(primary) > 0 { + builder.WriteString(",\n PRIMARY KEY (") + builder.WriteString(strings.Join(primary, ", ")) + builder.WriteByte(')') + } + builder.WriteString("\n)") + return builder.String(), nil +} + +func oracleColumnTypeDDL(column columnInfo) string { + dataType := strings.ToUpper(strings.TrimSpace(column.DataType)) + if dataType == "" { + return "VARCHAR2(4000)" + } + if strings.Contains(dataType, "(") { + return dataType + } + if isOracleCharacterType(dataType) && column.CharacterMaximumLength != nil && *column.CharacterMaximumLength > 0 { + return fmt.Sprintf("%s(%d)", dataType, *column.CharacterMaximumLength) + } + if dataType == "NUMBER" { + if column.NumericPrecision != nil && *column.NumericPrecision > 0 { + if column.NumericScale != nil && *column.NumericScale != 0 { + return fmt.Sprintf("NUMBER(%d,%d)", *column.NumericPrecision, *column.NumericScale) + } + return fmt.Sprintf("NUMBER(%d)", *column.NumericPrecision) + } + return "NUMBER" + } + if (dataType == "FLOAT" || dataType == "BINARY_FLOAT" || dataType == "BINARY_DOUBLE") && + column.NumericPrecision != nil && *column.NumericPrecision > 0 { + return fmt.Sprintf("%s(%d)", dataType, *column.NumericPrecision) + } + return dataType +} + +func isOracleCharacterType(dataType string) bool { + switch dataType { + case "CHAR", "VARCHAR2", "VARCHAR", "NCHAR", "NVARCHAR2", "RAW": + return true + default: + return false + } +} + +func (s *server) getExplainInfo(sqlText, database, schema string, timeoutSecs int) (string, error) { + if strings.TrimSpace(sqlText) == "" { + return "", errors.New("sql is required") + } + db, err := s.requireDB() + if err != nil { + return "", err + } + + ctx := context.Background() + var cancel context.CancelFunc + if timeoutSecs > 0 { + ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second) + } else { + ctx, cancel = context.WithCancel(ctx) + } + defer cancel() + + conn, err := db.Conn(ctx) + if err != nil { + return "", err + } + defer conn.Close() + + targetSchema := strings.TrimSpace(schema) + if targetSchema == "" && !strings.EqualFold(strings.TrimSpace(database), strings.TrimSpace(s.params.Database)) { + targetSchema = strings.TrimSpace(database) + } + if targetSchema != "" { + var originalSchema string + if err := conn.QueryRowContext(ctx, "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL").Scan(&originalSchema); err != nil { + return "", err + } + if !strings.EqualFold(originalSchema, targetSchema) { + if _, err := conn.ExecContext(ctx, "ALTER SESSION SET CURRENT_SCHEMA = "+quoteIdentifier(targetSchema)); err != nil { + return "", err + } + defer restoreOracleCurrentSchema(conn, originalSchema) + } + } + + statementID := "DBX_" + strings.ToUpper(strconv.FormatInt(time.Now().UnixNano(), 36)) + defer cleanupOracleExplainPlan(conn, statementID) + statementSQL := trimStatementSQL(sqlText) + explainArgs := oracleExplainPlanBindArgs(statementSQL) + if _, err := conn.ExecContext(ctx, "EXPLAIN PLAN SET STATEMENT_ID = '"+statementID+"' FOR "+statementSQL, explainArgs...); err != nil { + return "", err + } + planRows, err := conn.QueryContext( + ctx, + "SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', :1, 'TYPICAL +PREDICATE'))", + statementID, + ) + if err != nil { + return "", err + } + defer planRows.Close() + var builder strings.Builder + for planRows.Next() { + var line string + if err := planRows.Scan(&line); err != nil { + return "", err + } + builder.WriteString(line) + builder.WriteByte('\n') + } + return strings.TrimSpace(builder.String()), planRows.Err() +} + +func cleanupOracleExplainPlan(conn *sql.Conn, statementID string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = conn.ExecContext(ctx, "DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = :1", statementID) +} + +type oracleBindParam struct { + Name string + Positional bool +} + +func oracleExplainPlanBindArgs(sqlText string) []any { + params := oracleExplainPlanBindParams(sqlText) + args := make([]any, 0, len(params)) + for _, param := range params { + if param.Positional { + args = append(args, nil) + continue + } + args = append(args, sql.Named(param.Name, nil)) + } + return args +} + +func oracleExplainPlanBindParams(sqlText string) []oracleBindParam { + params := make([]oracleBindParam, 0) + seenNamed := map[string]bool{} + for pos := 0; pos < len(sqlText); pos++ { + switch sqlText[pos] { + case '\'': + pos = skipSingleQuotedSQL(sqlText, pos) + case '"': + pos = skipDoubleQuotedSQL(sqlText, pos) + case 'q', 'Q': + if end, ok := skipOracleAlternativeQuotedSQL(sqlText, pos); ok { + pos = end + } + case '-': + if pos+1 < len(sqlText) && sqlText[pos+1] == '-' { + pos = skipLineCommentSQL(sqlText, pos) + } + case '/': + if pos+1 < len(sqlText) && sqlText[pos+1] == '*' { + pos = skipBlockCommentSQL(sqlText, pos) + } + case ':': + param, end, ok := readOracleBindParam(sqlText, pos) + if !ok { + continue + } + if param.Positional { + params = append(params, param) + } else if key := strings.ToUpper(param.Name); !seenNamed[key] { + seenNamed[key] = true + params = append(params, param) + } + pos = end - 1 + } + } + return params +} + +func readOracleBindParam(sqlText string, pos int) (oracleBindParam, int, bool) { + if pos < 0 || pos+1 >= len(sqlText) || sqlText[pos] != ':' { + return oracleBindParam{}, pos, false + } + if pos > 0 && sqlText[pos-1] == ':' { + return oracleBindParam{}, pos, false + } + next := sqlText[pos+1] + if next >= '0' && next <= '9' { + end := pos + 2 + for end < len(sqlText) && sqlText[end] >= '0' && sqlText[end] <= '9' { + end++ + } + return oracleBindParam{Name: sqlText[pos+1 : end], Positional: true}, end, true + } + if !isOracleIdentifierStart(next) { + return oracleBindParam{}, pos, false + } + end := pos + 2 + for end < len(sqlText) && isOracleIdentifierPart(sqlText[end]) { + end++ + } + return oracleBindParam{Name: sqlText[pos+1 : end]}, end, true +} + +func restoreOracleCurrentSchema(conn *sql.Conn, schema string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = conn.ExecContext(ctx, "ALTER SESSION SET CURRENT_SCHEMA = "+quoteIdentifier(schema)) +} + +func (s *server) executeTransaction(params map[string]json.RawMessage) (queryResult, error) { + var payload struct { + Statements []string `json:"statements"` + Schema string `json:"schema"` + } + if err := decodeParams(params, &payload); err != nil { + return queryResult{}, err + } + db, err := s.requireDB() + if err != nil { + return queryResult{}, err + } + tx, err := db.Begin() + if err != nil { + return queryResult{}, err + } + if strings.TrimSpace(payload.Schema) != "" { + if _, err := tx.Exec("ALTER SESSION SET CURRENT_SCHEMA = " + quoteIdentifier(payload.Schema)); err != nil { + tx.Rollback() + return queryResult{}, err + } + } + var affected int64 + start := time.Now() + for _, statement := range payload.Statements { + statement = trimStatementSQL(statement) + if statement == "" { + continue + } + result, err := tx.Exec(statement) + if err != nil { + tx.Rollback() + return queryResult{}, err + } + count, _ := result.RowsAffected() + affected += count + } + if err := tx.Commit(); err != nil { + return queryResult{}, err + } + return queryResult{ + Columns: []string{}, + Rows: [][]any{}, + AffectedRows: affected, + ExecutionTimeMS: time.Since(start).Milliseconds(), + }, nil +} + +func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageResult, error) { + start := time.Now() + if strings.TrimSpace(opts.Schema) != "" { + if err := s.setSchema(opts.Schema); err != nil { + return queryPageResult{}, err + } + } + sqlText := trimStatementSQL(opts.SQL) + if !isQuerySQL(sqlText) { + result, err := s.executeQuery(opts) + return queryPageResult{ + Columns: result.Columns, + ColumnTypes: result.ColumnTypes, + Rows: result.Rows, + AffectedRows: result.AffectedRows, + ExecutionTimeMS: result.ExecutionTimeMS, + Truncated: result.Truncated, + SessionID: nil, + HasMore: false, + }, err + } + rows, err := s.queryRowsWithXMLTypeRewriteIfNeeded(sqlText) + if err != nil { + return queryPageResult{}, err + } + columns, err := rows.Columns() + if err != nil { + rows.Close() + return queryPageResult{}, err + } + columnTypes := columnTypeNames(rows) + maxRows := opts.MaxRows + if maxRows <= 0 { + maxRows = defaultMaxRows + } + session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows} + result, err := readQuerySessionPage(session, pageSize) + result.ExecutionTimeMS = time.Since(start).Milliseconds() + if err != nil { + rows.Close() + return queryPageResult{}, err + } + if result.HasMore { + sessionID := s.storeQuerySession(session) + result.SessionID = &sessionID + } else { + rows.Close() + } + return result, nil +} + +func (s *server) fetchQueryPage(sessionID string, pageSize int) (queryPageResult, error) { + session := s.sessions[sessionID] + if session == nil { + return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, SessionID: nil, HasMore: false}, nil + } + result, err := readQuerySessionPage(session, pageSize) + if err != nil { + s.closeQuerySession(sessionID) + return queryPageResult{}, err + } + if result.HasMore { + result.SessionID = &sessionID + } else { + s.closeQuerySession(sessionID) + } + return result, nil +} + +func (s *server) storeQuerySession(session *querySession) string { + s.nextSessionID++ + sessionID := fmt.Sprintf("oracle-go-%d", s.nextSessionID) + s.sessions[sessionID] = session + return sessionID +} + +func (s *server) startTableRead(opts queryOptions, pageSize int) (queryPageResult, error) { + start := time.Now() + if strings.TrimSpace(opts.Schema) != "" { + if err := s.setSchema(opts.Schema); err != nil { + return queryPageResult{}, err + } + } + sqlText := trimStatementSQL(opts.SQL) + if !isQuerySQL(sqlText) { + return queryPageResult{}, errors.New("table read requires a SELECT query") + } + rows, err := s.queryRowsWithXMLTypeRewriteIfNeeded(sqlText) + if err != nil { + return queryPageResult{}, err + } + columns, err := rows.Columns() + if err != nil { + rows.Close() + return queryPageResult{}, err + } + columnTypes := columnTypeNames(rows) + maxRows := opts.MaxRows + if maxRows <= 0 { + maxRows = defaultMaxRows + } + session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows} + result, err := readQuerySessionPage(session, pageSize) + result.ExecutionTimeMS = time.Since(start).Milliseconds() + if err != nil { + rows.Close() + return queryPageResult{}, err + } + if result.HasMore { + sessionID := s.storeTableReadSession(session) + result.SessionID = &sessionID + } else { + rows.Close() + } + return result, nil +} + +func (s *server) fetchTableReadPage(sessionID string, pageSize int) (queryPageResult, error) { + session := s.tableReadSessions[sessionID] + if session == nil { + return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, SessionID: nil, HasMore: false}, nil + } + result, err := readQuerySessionPage(session, pageSize) + if err != nil { + s.closeTableReadSession(sessionID) + return queryPageResult{}, err + } + if result.HasMore { + result.SessionID = &sessionID + } else { + s.closeTableReadSession(sessionID) + } + return result, nil +} + +func (s *server) storeTableReadSession(session *querySession) string { + s.nextTableReadSessionID++ + sessionID := fmt.Sprintf("oracle-go-table-%d", s.nextTableReadSessionID) + s.tableReadSessions[sessionID] = session + return sessionID +} + +func (s *server) closeQuerySession(sessionID string) bool { + session := s.sessions[sessionID] + if session == nil { + return false + } + session.rows.Close() + delete(s.sessions, sessionID) + return true +} + +func (s *server) closeTableReadSession(sessionID string) bool { + session := s.tableReadSessions[sessionID] + if session == nil { + return false + } + session.rows.Close() + delete(s.tableReadSessions, sessionID) + return true +} + +func (s *server) closeAllQuerySessions() { + for sessionID := range s.sessions { + s.closeQuerySession(sessionID) + } + for sessionID := range s.tableReadSessions { + s.closeTableReadSession(sessionID) + } +} + +func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) { + if pageSize <= 0 { + pageSize = defaultMaxRows + } + result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false} + for len(result.Rows) < pageSize && session.remaining > 0 { + if session.pending != nil { + result.Rows = append(result.Rows, session.pending) + session.pending = nil + session.remaining-- + continue + } + if !session.rows.Next() { + return result, session.rows.Err() + } + row, err := scanRow(session.rows, len(session.columns), session.columnTypes) + if err != nil { + return queryPageResult{}, err + } + result.Rows = append(result.Rows, row) + session.remaining-- + } + if session.remaining <= 0 { + result.Truncated = true + return result, nil + } + if session.rows.Next() { + row, err := scanRow(session.rows, len(session.columns), session.columnTypes) + if err != nil { + return queryPageResult{}, err + } + session.pending = row + result.HasMore = true + return result, nil + } + return result, session.rows.Err() +} + +func (s *server) executeQuery(opts queryOptions) (queryResult, error) { + start := time.Now() + if strings.TrimSpace(opts.Schema) != "" { + if err := s.setSchema(opts.Schema); err != nil { + return queryResult{}, err + } + } + sqlText := trimStatementSQL(opts.SQL) + maxRows := opts.MaxRows + if maxRows <= 0 { + maxRows = defaultMaxRows + } + if isQuerySQL(sqlText) { + result, err := s.executeSelect(sqlText, maxRows) + result.ExecutionTimeMS = time.Since(start).Milliseconds() + return result, err + } + db, err := s.requireDB() + if err != nil { + return queryResult{}, err + } + execResult, err := db.Exec(sqlText) + if err != nil { + return queryResult{}, err + } + affected, _ := execResult.RowsAffected() + return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds()}, nil +} + +func (s *server) executeSelect(sqlText string, maxRows int) (queryResult, error) { + rows, err := s.queryRowsWithXMLTypeRewriteIfNeeded(sqlText) + if err != nil { + return queryResult{}, err + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return queryResult{}, err + } + columnTypes := columnTypeNames(rows) + result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: [][]any{}} + for rows.Next() { + if len(result.Rows) >= maxRows { + result.Truncated = true + break + } + values, err := scanRow(rows, len(columns), columnTypes) + if err != nil { + return queryResult{}, err + } + result.Rows = append(result.Rows, values) + } + return result, rows.Err() +} + +func scanRow(rows *sql.Rows, columnCount int, columnTypes []string) ([]any, error) { + values := make([]any, columnCount) + scanTargets := make([]any, columnCount) + for i := range values { + scanTargets[i] = &values[i] + } + if err := rows.Scan(scanTargets...); err != nil { + return nil, err + } + for i, value := range values { + values[i] = normalizeValue(value, columnTypeAt(columnTypes, i)) + } + return values, nil +} + +func columnTypeAt(columnTypes []string, index int) string { + if index < 0 || index >= len(columnTypes) { + return "" + } + return columnTypes[index] +} + +func columnTypeNames(rows *sql.Rows) []string { + types, err := rows.ColumnTypes() + if err != nil { + return []string{} + } + result := make([]string, 0, len(types)) + for _, columnType := range types { + result = append(result, columnType.DatabaseTypeName()) + } + return result +} + +func (s *server) queryRowsWithXMLTypeRewriteIfNeeded(sqlText string) (*sql.Rows, error) { + rows, err := s.queryRows(sqlText, nil) + if err != nil { + return nil, err + } + if !rowsContainOracleXMLType(rows) { + return rows, nil + } + rewritten, err := s.rewriteXMLTypeSelectSQL(sqlText) + if err != nil { + rows.Close() + return nil, err + } + if rewritten == sqlText { + return rows, nil + } + // Only pay the ALL_TAB_COLUMNS rewrite cost when the result metadata shows + // XMLTYPE. Ordinary Oracle queries should not run dictionary probes first. + rows.Close() + return s.queryRows(rewritten, nil) +} + +func rowsContainOracleXMLType(rows *sql.Rows) bool { + types, err := rows.ColumnTypes() + if err != nil { + return false + } + typeNames := make([]string, 0, len(types)) + for _, columnType := range types { + typeNames = append(typeNames, columnType.DatabaseTypeName()) + } + return oracleColumnTypeNamesContainXMLType(typeNames) +} + +func oracleColumnTypeNamesContainXMLType(typeNames []string) bool { + for _, typeName := range typeNames { + if isOracleXMLType(typeName) { + return true + } + } + return false +} + +func (s *server) rewriteXMLTypeSelectSQL(sqlText string) (string, error) { + return rewriteOracleXMLTypeSelectSQL(sqlText, s.loadOracleColumnMeta) +} + +func rewriteOracleXMLTypeSelectSQL(sqlText string, loadColumns oracleColumnMetaLoader) (string, error) { + rewritten, _, err := rewriteOracleXMLTypeSelectSQLDepth(sqlText, loadColumns, 0) + return rewritten, err +} + +func rewriteOracleXMLTypeSelectSQLDepth(sqlText string, loadColumns oracleColumnMetaLoader, depth int) (string, bool, error) { + if depth > 8 { + return sqlText, false, nil + } + if rewritten, changed, handled, err := rewriteDirectOracleXMLTypeSelectSQL(sqlText, loadColumns); handled || err != nil { + return rewritten, changed, err + } + rewritten, changed, err := rewriteNestedOracleSelects(sqlText, loadColumns, depth) + return rewritten, changed, err +} + +func rewriteNestedOracleSelects(sqlText string, loadColumns oracleColumnMetaLoader, depth int) (string, bool, error) { + var builder strings.Builder + changed := false + last := 0 + for pos := 0; pos < len(sqlText); pos++ { + switch sqlText[pos] { + case '\'': + pos = skipSingleQuotedSQL(sqlText, pos) + case '"': + pos = skipDoubleQuotedSQL(sqlText, pos) + case '-': + if pos+1 < len(sqlText) && sqlText[pos+1] == '-' { + pos = skipLineCommentSQL(sqlText, pos) + } + case '/': + if pos+1 < len(sqlText) && sqlText[pos+1] == '*' { + pos = skipBlockCommentSQL(sqlText, pos) + } + case '(': + end := findMatchingSQLParen(sqlText, pos) + if end < 0 { + return sqlText, false, nil + } + inner := sqlText[pos+1 : end] + if startsWithSQLKeyword(trimLeadingSQLComments(inner), "select") { + rewrittenInner, innerChanged, err := rewriteOracleXMLTypeSelectSQLDepth(inner, loadColumns, depth+1) + if err != nil { + return "", false, err + } + if innerChanged { + builder.WriteString(sqlText[last : pos+1]) + builder.WriteString(rewrittenInner) + last = end + changed = true + } + } + pos = end + } + } + if !changed { + return sqlText, false, nil + } + builder.WriteString(sqlText[last:]) + return builder.String(), true, nil +} + +func rewriteDirectOracleXMLTypeSelectSQL(sqlText string, loadColumns oracleColumnMetaLoader) (string, bool, bool, error) { + selectStart := leadingSQLSelectListStart(sqlText) + if selectStart < 0 { + return sqlText, false, false, nil + } + fromIdx := findTopLevelSQLKeyword(sqlText, selectStart, "from") + if fromIdx < 0 { + return sqlText, false, false, nil + } + selectListPrefix, selectList := splitOracleSelectListModifier(sqlText[selectStart:fromIdx]) + tableRef, ok := parseSingleOracleTableRef(sqlText[fromIdx+len("from"):]) + if !ok { + return sqlText, false, false, nil + } + items := splitTopLevelSQLList(selectList) + if len(items) == 0 || !oracleSelectListMayReferenceXMLType(items) { + return sqlText, false, true, nil + } + columns, err := loadColumns(tableRef.Schema, tableRef.Table) + if err != nil { + return "", false, true, err + } + if !oracleColumnsHaveXMLType(columns) { + return sqlText, false, true, nil + } + rewrittenItems, changed := rewriteOracleSelectItemsForXMLType(items, columns, tableRef) + if !changed { + return sqlText, false, true, nil + } + var builder strings.Builder + builder.WriteString(sqlText[:selectStart]) + builder.WriteString(selectListPrefix) + builder.WriteString(strings.Join(rewrittenItems, ", ")) + builder.WriteByte(' ') + builder.WriteString(sqlText[fromIdx:]) + return builder.String(), true, true, nil +} + +type oracleTableRef struct { + Schema string + Table string + Alias string + AliasText string +} + +type oracleIdentifierToken struct { + Name string + Text string + Quoted bool +} + +func parseSingleOracleTableRef(fromSQL string) (oracleTableRef, bool) { + pos := skipSQLWhitespace(fromSQL, 0) + if pos >= len(fromSQL) || fromSQL[pos] == '(' { + return oracleTableRef{}, false + } + first, next, ok := readOracleIdentifierToken(fromSQL, pos) + if !ok { + return oracleTableRef{}, false + } + ref := oracleTableRef{Table: first.Name} + pos = skipSQLWhitespace(fromSQL, next) + if pos < len(fromSQL) && fromSQL[pos] == '.' { + second, afterSecond, ok := readOracleIdentifierToken(fromSQL, skipSQLWhitespace(fromSQL, pos+1)) + if !ok { + return oracleTableRef{}, false + } + ref.Schema = first.Name + ref.Table = second.Name + pos = skipSQLWhitespace(fromSQL, afterSecond) + } + if pos < len(fromSQL) { + if strings.HasPrefix(strings.TrimLeft(fromSQL[pos:], " \t\r\n"), ",") { + return oracleTableRef{}, false + } + if nextKeywordIsOracleClause(fromSQL[pos:]) { + return ref, true + } + if startsWithSQLKeyword(fromSQL[pos:], "join") || + startsWithSQLKeyword(fromSQL[pos:], "inner") || + startsWithSQLKeyword(fromSQL[pos:], "left") || + startsWithSQLKeyword(fromSQL[pos:], "right") || + startsWithSQLKeyword(fromSQL[pos:], "full") || + startsWithSQLKeyword(fromSQL[pos:], "cross") { + return oracleTableRef{}, false + } + alias, afterAlias, ok := readOracleIdentifierToken(fromSQL, pos) + if ok && !oracleIdentifierIsClause(alias.Name) { + ref.Alias = alias.Name + ref.AliasText = alias.Text + pos = skipSQLWhitespace(fromSQL, afterAlias) + } + if strings.HasPrefix(strings.TrimLeft(fromSQL[pos:], " \t\r\n"), ",") || + startsWithSQLKeyword(fromSQL[pos:], "join") || + startsWithSQLKeyword(fromSQL[pos:], "inner") || + startsWithSQLKeyword(fromSQL[pos:], "left") || + startsWithSQLKeyword(fromSQL[pos:], "right") || + startsWithSQLKeyword(fromSQL[pos:], "full") || + startsWithSQLKeyword(fromSQL[pos:], "cross") { + return oracleTableRef{}, false + } + } + return ref, true +} + +func splitOracleSelectListModifier(selectList string) (string, string) { + trimmedLeft := strings.TrimLeft(selectList, " \t\r\n") + prefixLen := len(selectList) - len(trimmedLeft) + for _, keyword := range []string{"distinct", "all"} { + if startsWithSQLKeyword(trimmedLeft, keyword) { + modifierEnd := prefixLen + len(keyword) + for modifierEnd < len(selectList) && isSQLWhitespace(selectList[modifierEnd]) { + modifierEnd++ + } + return selectList[:modifierEnd], selectList[modifierEnd:] + } + } + return selectList[:prefixLen], selectList[prefixLen:] +} + +func oracleSelectListMayReferenceXMLType(items []string) bool { + for _, item := range items { + if _, ok := parseOracleStarSelectItem(item); ok { + return true + } + if _, _, _, ok := parseOracleColumnSelectItem(item); ok { + return true + } + } + return false +} + +func rewriteOracleSelectItemsForXMLType(items []string, columns []oracleColumnMeta, tableRef oracleTableRef) ([]string, bool) { + xmlColumns := map[string]oracleColumnMeta{} + for _, column := range columns { + if isOracleXMLType(column.DataType) { + xmlColumns[oracleIdentifierKey(column.Name)] = column + } + } + rewritten := make([]string, 0, len(items)) + changed := false + for _, item := range items { + if qualifier, ok := parseOracleStarSelectItem(item); ok && oracleQualifierMatchesTable(qualifier, tableRef) { + for _, column := range columns { + rewritten = append(rewritten, oracleSelectExpressionForColumn(column, tableRef, xmlColumns)) + } + changed = true + continue + } + qualifier, column, alias, ok := parseOracleColumnSelectItem(item) + if ok && oracleQualifierMatchesTable(qualifier, tableRef) { + if meta, isXML := xmlColumns[oracleIdentifierKey(column.Name)]; isXML { + outputAlias := alias + if outputAlias == "" { + outputAlias = quoteIdentifier(meta.Name) + } + rewritten = append(rewritten, oracleXMLSerializeExpression(oracleColumnRef(qualifier, meta.Name), outputAlias)) + changed = true + continue + } + } + rewritten = append(rewritten, item) + } + return rewritten, changed +} + +func oracleSelectExpressionForColumn(column oracleColumnMeta, tableRef oracleTableRef, xmlColumns map[string]oracleColumnMeta) string { + qualifier := "" + if tableRef.AliasText != "" { + qualifier = tableRef.AliasText + } + if _, isXML := xmlColumns[oracleIdentifierKey(column.Name)]; isXML { + return oracleXMLSerializeExpression(oracleColumnRef(qualifier, column.Name), quoteIdentifier(column.Name)) + } + return oracleColumnRef(qualifier, column.Name) +} + +func oracleXMLSerializeExpression(columnRef, alias string) string { + // go-ora v2.9.0 does not fully decode Oracle XMLTYPE result payloads, + // especially when 11g switches larger values to locator-based transfer. + return fmt.Sprintf("XMLSERIALIZE(CONTENT %s AS CLOB) AS %s", columnRef, alias) +} + +func oracleColumnRef(qualifier, column string) string { + if strings.TrimSpace(qualifier) == "" { + return quoteIdentifier(column) + } + return qualifier + "." + quoteIdentifier(column) +} + +func parseOracleStarSelectItem(item string) (string, bool) { + trimmed := strings.TrimSpace(item) + if trimmed == "*" { + return "", true + } + qualifier, pos, ok := readOracleIdentifierToken(trimmed, 0) + if !ok { + return "", false + } + pos = skipSQLWhitespace(trimmed, pos) + if pos >= len(trimmed) || trimmed[pos] != '.' { + return "", false + } + pos = skipSQLWhitespace(trimmed, pos+1) + if pos < len(trimmed) && trimmed[pos] == '*' && strings.TrimSpace(trimmed[pos+1:]) == "" { + return qualifier.Text, true + } + return "", false +} + +func parseOracleColumnSelectItem(item string) (qualifier string, column oracleIdentifierToken, alias string, ok bool) { + trimmed := strings.TrimSpace(item) + first, pos, ok := readOracleIdentifierToken(trimmed, 0) + if !ok { + return "", oracleIdentifierToken{}, "", false + } + column = first + pos = skipSQLWhitespace(trimmed, pos) + if pos < len(trimmed) && trimmed[pos] == '.' { + second, afterSecond, ok := readOracleIdentifierToken(trimmed, skipSQLWhitespace(trimmed, pos+1)) + if !ok { + return "", oracleIdentifierToken{}, "", false + } + qualifier = first.Text + column = second + pos = skipSQLWhitespace(trimmed, afterSecond) + } + if pos >= len(trimmed) { + return qualifier, column, "", true + } + if startsWithSQLKeyword(trimmed[pos:], "as") { + aliasToken, afterAlias, ok := readOracleIdentifierToken(trimmed, skipSQLWhitespace(trimmed, pos+len("as"))) + if !ok || strings.TrimSpace(trimmed[afterAlias:]) != "" { + return "", oracleIdentifierToken{}, "", false + } + return qualifier, column, aliasToken.Text, true + } + aliasToken, afterAlias, ok := readOracleIdentifierToken(trimmed, pos) + if !ok || strings.TrimSpace(trimmed[afterAlias:]) != "" { + return "", oracleIdentifierToken{}, "", false + } + return qualifier, column, aliasToken.Text, true +} + +func oracleQualifierMatchesTable(qualifier string, tableRef oracleTableRef) bool { + if strings.TrimSpace(qualifier) == "" { + return true + } + key := oracleIdentifierKey(unquoteOracleIdentifierText(qualifier)) + if tableRef.Alias != "" && key == oracleIdentifierKey(tableRef.Alias) { + return true + } + return key == oracleIdentifierKey(tableRef.Table) +} + +func oracleColumnsHaveXMLType(columns []oracleColumnMeta) bool { + for _, column := range columns { + if isOracleXMLType(column.DataType) { + return true + } + } + return false +} + +func isOracleXMLType(dataType string) bool { + normalized := strings.ToUpper(strings.TrimSpace(dataType)) + return normalized == "XMLTYPE" || normalized == "SYS.XMLTYPE" +} + +func leadingSQLSelectListStart(sqlText string) int { + trimmed := trimLeadingSQLComments(sqlText) + prefixLen := len(sqlText) - len(trimmed) + if !startsWithSQLKeyword(trimmed, "select") { + return -1 + } + return prefixLen + len("select") +} + +func splitTopLevelSQLList(value string) []string { + var result []string + start := 0 + depth := 0 + for pos := 0; pos < len(value); pos++ { + switch value[pos] { + case '\'': + pos = skipSingleQuotedSQL(value, pos) + case '"': + pos = skipDoubleQuotedSQL(value, pos) + case '-': + if pos+1 < len(value) && value[pos+1] == '-' { + pos = skipLineCommentSQL(value, pos) + } + case '/': + if pos+1 < len(value) && value[pos+1] == '*' { + pos = skipBlockCommentSQL(value, pos) + } + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + result = append(result, strings.TrimSpace(value[start:pos])) + start = pos + 1 + } + } + } + tail := strings.TrimSpace(value[start:]) + if tail != "" { + result = append(result, tail) + } + return result +} + +func findTopLevelSQLKeyword(sqlText string, start int, keyword string) int { + depth := 0 + for pos := start; pos < len(sqlText); pos++ { + switch sqlText[pos] { + case '\'': + pos = skipSingleQuotedSQL(sqlText, pos) + case '"': + pos = skipDoubleQuotedSQL(sqlText, pos) + case '-': + if pos+1 < len(sqlText) && sqlText[pos+1] == '-' { + pos = skipLineCommentSQL(sqlText, pos) + } + case '/': + if pos+1 < len(sqlText) && sqlText[pos+1] == '*' { + pos = skipBlockCommentSQL(sqlText, pos) + } + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + default: + if depth == 0 && sqlKeywordAt(sqlText, pos, keyword) { + return pos + } + } + } + return -1 +} + +func findMatchingSQLParen(sqlText string, open int) int { + depth := 0 + for pos := open; pos < len(sqlText); pos++ { + switch sqlText[pos] { + case '\'': + pos = skipSingleQuotedSQL(sqlText, pos) + case '"': + pos = skipDoubleQuotedSQL(sqlText, pos) + case '-': + if pos+1 < len(sqlText) && sqlText[pos+1] == '-' { + pos = skipLineCommentSQL(sqlText, pos) + } + case '/': + if pos+1 < len(sqlText) && sqlText[pos+1] == '*' { + pos = skipBlockCommentSQL(sqlText, pos) + } + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return pos + } + } + } + return -1 +} + +func readOracleIdentifierToken(value string, pos int) (oracleIdentifierToken, int, bool) { + pos = skipSQLWhitespace(value, pos) + if pos >= len(value) { + return oracleIdentifierToken{}, pos, false + } + if value[pos] == '"' { + end := pos + 1 + var builder strings.Builder + for end < len(value) { + if value[end] == '"' { + if end+1 < len(value) && value[end+1] == '"' { + builder.WriteByte('"') + end += 2 + continue + } + return oracleIdentifierToken{Name: builder.String(), Text: value[pos : end+1], Quoted: true}, end + 1, true + } + builder.WriteByte(value[end]) + end++ + } + return oracleIdentifierToken{}, pos, false + } + if !isOracleIdentifierStart(value[pos]) { + return oracleIdentifierToken{}, pos, false + } + end := pos + 1 + for end < len(value) && isOracleIdentifierPart(value[end]) { + end++ + } + text := value[pos:end] + return oracleIdentifierToken{Name: strings.ToUpper(text), Text: text}, end, true +} + +func unquoteOracleIdentifierText(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + return strings.ReplaceAll(value[1:len(value)-1], `""`, `"`) + } + return strings.ToUpper(value) +} + +func oracleIdentifierKey(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func oracleIdentifierIsClause(value string) bool { + switch oracleIdentifierKey(value) { + case "WHERE", "GROUP", "ORDER", "HAVING", "CONNECT", "START", "MODEL", "FETCH", "OFFSET", "UNION", "MINUS", "INTERSECT": + return true + default: + return false + } +} + +func nextKeywordIsOracleClause(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return true + } + token, _, ok := readOracleIdentifierToken(trimmed, 0) + return ok && oracleIdentifierIsClause(token.Name) +} + +func isOracleIdentifierStart(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_' || ch == '$' || ch == '#' +} + +func isOracleIdentifierPart(ch byte) bool { + return isOracleIdentifierStart(ch) || (ch >= '0' && ch <= '9') +} + +func skipSQLWhitespace(value string, pos int) int { + for pos < len(value) && isSQLWhitespace(value[pos]) { + pos++ + } + return pos +} + +func isSQLWhitespace(ch byte) bool { + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' +} + +func skipSingleQuotedSQL(value string, pos int) int { + pos++ + for pos < len(value) { + if value[pos] == '\'' { + if pos+1 < len(value) && value[pos+1] == '\'' { + pos += 2 + continue + } + return pos + } + pos++ + } + return len(value) - 1 +} + +func skipOracleAlternativeQuotedSQL(value string, pos int) (int, bool) { + if pos+2 >= len(value) || (value[pos] != 'q' && value[pos] != 'Q') || value[pos+1] != '\'' { + return pos, false + } + open := value[pos+2] + close := open + switch open { + case '[': + close = ']' + case '{': + close = '}' + case '(': + close = ')' + case '<': + close = '>' + } + for end := pos + 3; end+1 < len(value); end++ { + if value[end] == close && value[end+1] == '\'' { + return end + 1, true + } + } + return len(value) - 1, true +} + +func skipDoubleQuotedSQL(value string, pos int) int { + pos++ + for pos < len(value) { + if value[pos] == '"' { + if pos+1 < len(value) && value[pos+1] == '"' { + pos += 2 + continue + } + return pos + } + pos++ + } + return len(value) - 1 +} + +func skipLineCommentSQL(value string, pos int) int { + for pos < len(value) { + if value[pos] == '\n' || value[pos] == '\r' { + return pos + } + pos++ + } + return len(value) - 1 +} + +func skipBlockCommentSQL(value string, pos int) int { + end := strings.Index(value[pos+2:], "*/") + if end < 0 { + return len(value) - 1 + } + return pos + end + 3 +} + +func (s *server) setSchema(schema string) error { + db, err := s.requireDB() + if err != nil { + return err + } + _, err = db.Exec("ALTER SESSION SET CURRENT_SCHEMA = " + quoteIdentifier(schema)) + return err +} + +func (s *server) queryRows(sqlText string, args []any) (*sql.Rows, error) { + db, err := s.requireDB() + if err != nil { + return nil, err + } + if len(args) == 0 { + return db.Query(sqlText) + } + return db.Query(sqlText, args...) +} + +func decodeParams(params map[string]json.RawMessage, target any) error { + if params == nil { + params = map[string]json.RawMessage{} + } + data, err := json.Marshal(params) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func stringParam(params map[string]json.RawMessage, key string) string { + if params == nil || len(params[key]) == 0 { + return "" + } + var value string + _ = json.Unmarshal(params[key], &value) + return value +} + +func stringSliceParam(params map[string]json.RawMessage, key string) []string { + if params == nil || len(params[key]) == 0 { + return nil + } + var value []string + if err := json.Unmarshal(params[key], &value); err != nil { + return nil + } + return value +} + +func intParam(params map[string]json.RawMessage, key string) int { + if params == nil || len(params[key]) == 0 { + return 0 + } + var value int + _ = json.Unmarshal(params[key], &value) + return value +} + +func errorResponse(id json.RawMessage, err error) response { + return response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: -1, Message: err.Error()}} +} + +func parseURLParams(raw string) map[string]string { + result := map[string]string{} + values, err := url.ParseQuery(raw) + if err != nil { + return result + } + for key, items := range values { + if len(items) > 0 { + result[key] = items[len(items)-1] + } + } + return result +} + +func trimStatementSQL(sqlText string) string { + trimmed := stripTrailingSlashDelimiter(strings.TrimSpace(sqlText)) + if isOraclePlSQLBlock(trimmed) { + return trimmed + } + return strings.TrimRight(trimmed, "; \t\r\n") +} + +func stripTrailingSlashDelimiter(sqlText string) string { + trimmed := strings.TrimSpace(sqlText) + if !strings.HasSuffix(trimmed, "/") { + return trimmed + } + slashStart := len(trimmed) - 1 + lineStart := strings.LastIndex(trimmed[:slashStart], "\n") + 1 + if strings.TrimSpace(trimmed[lineStart:slashStart]) != "" { + return trimmed + } + beforeSlash := strings.TrimSpace(trimmed[:lineStart]) + // SQL*Plus uses a standalone slash to execute PL/SQL blocks; go-ora needs + // only the block text and not that client-side delimiter. + if isOraclePlSQLBlock(beforeSlash) { + return beforeSlash + } + return trimmed +} + +func isOraclePlSQLBlock(sqlText string) bool { + trimmed := strings.TrimSpace(sqlText) + start := trimLeadingSQLComments(trimmed) + if !oraclePlSQLBlockStartRegexp.MatchString(start) { + return false + } + upper := strings.ToUpper(trimmed) + if oraclePlSQLBlockEndRegexp.MatchString(upper) { + return true + } + matches := oracleNamedPlSQLBlockEndRegexp.FindStringSubmatch(upper) + if len(matches) != 2 { + return false + } + switch matches[1] { + case "IF", "LOOP", "CASE": + return false + default: + return true + } +} + +func isQuerySQL(sqlText string) bool { + executable := trimLeadingSQLComments(sqlText) + return startsWithSQLKeyword(executable, "select") || startsWithSQLKeyword(executable, "with") +} + +func trimLeadingSQLComments(sqlText string) string { + remaining := strings.TrimSpace(sqlText) + for { + switch { + case strings.HasPrefix(remaining, "--"): + lineEnd := strings.IndexAny(remaining, "\r\n") + if lineEnd < 0 { + return "" + } + remaining = strings.TrimSpace(remaining[lineEnd+1:]) + case strings.HasPrefix(remaining, "/*"): + commentEnd := strings.Index(remaining[2:], "*/") + if commentEnd < 0 { + return "" + } + remaining = strings.TrimSpace(remaining[commentEnd+4:]) + default: + return remaining + } + } +} + +func startsWithSQLKeyword(sqlText, keyword string) bool { + sqlText = strings.TrimSpace(sqlText) + if len(sqlText) < len(keyword) || !strings.EqualFold(sqlText[:len(keyword)], keyword) { + return false + } + if len(sqlText) == len(keyword) { + return true + } + next := sqlText[len(keyword)] + return !((next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') || (next >= '0' && next <= '9') || next == '_' || next == '$') +} + +func sqlKeywordAt(sqlText string, pos int, keyword string) bool { + if pos < 0 || pos+len(keyword) > len(sqlText) || !strings.EqualFold(sqlText[pos:pos+len(keyword)], keyword) { + return false + } + if pos > 0 && isOracleIdentifierPart(sqlText[pos-1]) { + return false + } + if pos+len(keyword) >= len(sqlText) { + return true + } + return !isOracleIdentifierPart(sqlText[pos+len(keyword)]) +} + +func quoteIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} + +func normalizeValue(value any, columnTypeName string) any { + switch v := value.(type) { + case nil: + return nil + case []byte: + // Oracle RAW-like columns are binary data; decoding them as text produces mojibake. + if isOracleBinaryColumnType(columnTypeName) { + return bytesToHex(v) + } + return string(v) + case time.Time: + return v.Format(time.RFC3339Nano) + case int64, float64, bool, string: + return v + case fmt.Stringer: + return v.String() + default: + return fmt.Sprint(v) + } +} + +func isOracleBinaryColumnType(columnTypeName string) bool { + normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(columnTypeName), " ", "")) + switch normalized { + case "RAW", "VARRAW", "LONGRAW", "LONGVARRAW", "BLOB", "BFILE", "OCIBLOBLOCATOR", "OCIFILELOCATOR": + return true + default: + return false + } +} + +func bytesToHex(bytes []byte) string { + const digits = "0123456789abcdef" + result := make([]byte, 2+len(bytes)*2) + result[0] = '0' + result[1] = 'x' + for i, b := range bytes { + result[2+i*2] = digits[b>>4] + result[3+i*2] = digits[b&0x0f] + } + return string(result) +} + +func emptyIfNil[T any](values []T) []T { + if values == nil { + return []T{} + } + return values +} + +func intPtrFromString(value string) *int { + if value == "" { + return nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return nil + } + return &parsed +} diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go new file mode 100644 index 0000000..dfc1287 --- /dev/null +++ b/agents/drivers/oracle-go/main_test.go @@ -0,0 +1,865 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "net/url" + "os" + "reflect" + "strings" + "testing" +) + +func TestHandshakeResponse(t *testing.T) { + s := newServer() + resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":7,"method":"handshake","params":{"appVersion":"dev"}}`) + if shutdown { + t.Fatal("handshake should not shut down the server") + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + data, err := json.Marshal(resp.Result) + if err != nil { + t.Fatal(err) + } + var result struct { + ProtocolVersion int `json:"protocolVersion"` + AgentProtocolVersion int `json:"agentProtocolVersion"` + Capabilities []string `json:"capabilities"` + } + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + if result.ProtocolVersion != 1 || result.AgentProtocolVersion != 1 { + t.Fatalf("unexpected protocol versions: %+v", result) + } + contract := protocolContract(t) + if result.ProtocolVersion != contract.ProtocolVersion || result.AgentProtocolVersion != contract.ProtocolVersion { + t.Fatalf("handshake protocol versions do not match contract: result=%+v contract=%+v", result, contract) + } + for _, capability := range result.Capabilities { + if !contains(contract.AllCapabilities, capability) { + t.Fatalf("handshake returned capability %q outside protocol contract %v", capability, contract.AllCapabilities) + } + } + if !contains(result.Capabilities, "query") || !contains(result.Capabilities, "metadata") { + t.Fatalf("expected query and metadata capabilities, got %v", result.Capabilities) + } +} + +func TestCloseMissingQuerySessionReturnsFalse(t *testing.T) { + s := newServer() + resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":8,"method":"close_query_session","params":{"sessionId":"missing"}}`) + if shutdown { + t.Fatal("close_query_session should not shut down the server") + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + if resp.Result != false { + t.Fatalf("expected false result, got %#v", resp.Result) + } +} + +func TestMissingTableReadSessionMethodsReturnEmptyOrFalse(t *testing.T) { + s := newServer() + + fetchResp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":9,"method":"fetch_table_read_page","params":{"sessionId":"missing","pageSize":10}}`) + if shutdown { + t.Fatal("fetch_table_read_page should not shut down the server") + } + if fetchResp.Error != nil { + t.Fatalf("unexpected fetch error: %v", fetchResp.Error) + } + data, err := json.Marshal(fetchResp.Result) + if err != nil { + t.Fatal(err) + } + var page queryPageResult + if err := json.Unmarshal(data, &page); err != nil { + t.Fatal(err) + } + if len(page.Columns) != 0 || len(page.ColumnTypes) != 0 || len(page.Rows) != 0 || page.HasMore || page.SessionID != nil { + t.Fatalf("missing table read session should return empty page, got %+v", page) + } + + closeResp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":10,"method":"close_table_read_session","params":{"sessionId":"missing"}}`) + if shutdown { + t.Fatal("close_table_read_session should not shut down the server") + } + if closeResp.Error != nil { + t.Fatalf("unexpected close error: %v", closeResp.Error) + } + if closeResp.Result != false { + t.Fatalf("expected false result, got %#v", closeResp.Result) + } +} + +func TestEmptyResultSlicesMarshalAsArrays(t *testing.T) { + data, err := json.Marshal(queryResult{}) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"column_types":null`) || strings.Contains(text, `"rows":null`) { + t.Fatalf("query result should marshal nil slices as arrays: %s", text) + } + if !strings.Contains(text, `"column_types":[]`) { + t.Fatalf("query result should marshal empty column types array: %s", text) + } + + data, err = json.Marshal(indexInfo{}) + if err != nil { + t.Fatal(err) + } + text = string(data) + if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"included_columns":null`) { + t.Fatalf("index info should marshal nil slices as arrays: %s", text) + } +} + +func TestGetTableDDLResultMarshalsAsString(t *testing.T) { + data, err := json.Marshal("CREATE TABLE HR.ORDERS (ID NUMBER)") + if err != nil { + t.Fatal(err) + } + var ddl string + if err := json.Unmarshal(data, &ddl); err != nil { + t.Fatalf("get_table_ddl result must deserialize as a string: %v", err) + } +} + +func TestNormalizeValueFormatsOracleBinaryColumnsAsHex(t *testing.T) { + tests := map[string]string{ + "RAW": "0x000f10ff", + "raw": "0x000f10ff", + "LongRaw": "0x000f10ff", + "LONG RAW": "0x000f10ff", + "LongVarRaw": "0x000f10ff", + "OCIBlobLocator": "0x000f10ff", + } + + for columnType, want := range tests { + if got := normalizeValue([]byte{0x00, 0x0f, 0x10, 0xff}, columnType); got != want { + t.Fatalf("normalizeValue RAW bytes for %q = %#v, want %q", columnType, got, want) + } + } +} + +func TestNormalizeValueKeepsNonBinaryBytesAsText(t *testing.T) { + if got := normalizeValue([]byte("hello"), "VARCHAR2"); got != "hello" { + t.Fatalf("normalizeValue text bytes = %#v, want %q", got, "hello") + } + if got := normalizeValue([]byte("legacy"), ""); got != "legacy" { + t.Fatalf("normalizeValue bytes without metadata = %#v, want %q", got, "legacy") + } +} + +func TestNormalizeDDLObjectType(t *testing.T) { + tests := map[string]string{ + "": "", + "table": "TABLE", + "VIEW": "VIEW", + "materialized view": "MATERIALIZED_VIEW", + "MATERIALIZED_VIEW": "MATERIALIZED_VIEW", + "procedure": "", + } + for input, want := range tests { + if got := normalizeDDLObjectType(input); got != want { + t.Fatalf("normalizeDDLObjectType(%q) = %q, want %q", input, got, want) + } + } +} + +func TestIsQuerySQLSkipsLeadingComments(t *testing.T) { + tests := []string{ + "-- 测试\nSELECT * FROM (SELECT * FROM \"DBX_TEST\".\"ORDERS_10K\") WHERE ROWNUM <= 100", + "/* explain */\nSELECT * FROM dual", + "-- comment\r\nWITH rows AS (SELECT 1 FROM dual) SELECT * FROM rows", + } + for _, sqlText := range tests { + if !isQuerySQL(sqlText) { + t.Fatalf("expected SQL to be treated as query: %s", sqlText) + } + } +} + +func TestIsQuerySQLRequiresKeywordBoundary(t *testing.T) { + tests := []string{ + "-- comment only", + "selectivity FROM stats", + "withdraw FROM account", + "/* unterminated comment", + } + for _, sqlText := range tests { + if isQuerySQL(sqlText) { + t.Fatalf("expected SQL not to be treated as query: %s", sqlText) + } + } +} + +func TestTrimStatementSQLPreservesAnonymousPLSQLBlockTerminator(t *testing.T) { + sqlText := `DECLARE + PRE_TRD_DATE INTEGER ; +BEGIN + SELECT 1 + 2 INTO PRE_TRD_DATE FROM DUAL; +END;` + + if got := trimStatementSQL(sqlText); got != sqlText { + t.Fatalf("trimStatementSQL() = %q, want full PL/SQL block %q", got, sqlText) + } +} + +func TestTrimStatementSQLStripsSlashDelimiterAfterPLSQLBlock(t *testing.T) { + sqlText := "BEGIN\n NULL;\nEND;\n/" + want := "BEGIN\n NULL;\nEND;" + + if got := trimStatementSQL(sqlText); got != want { + t.Fatalf("trimStatementSQL() = %q, want %q", got, want) + } +} + +func TestTrimStatementSQLPreservesCreatePLSQLObjectTerminator(t *testing.T) { + tests := []string{ + "CREATE OR REPLACE PROCEDURE p AS\nBEGIN\n NULL;\nEND;", + "CREATE OR REPLACE FUNCTION f RETURN NUMBER AS\nBEGIN\n RETURN 1;\nEND;", + "CREATE OR REPLACE PACKAGE pkg_utils AS\n FUNCTION get_version RETURN VARCHAR2;\nEND pkg_utils;", + } + for _, sqlText := range tests { + if got := trimStatementSQL(sqlText); got != sqlText { + t.Fatalf("trimStatementSQL() = %q, want full PL/SQL object %q", got, sqlText) + } + } +} + +func TestTrimStatementSQLStripsSlashDelimiterAfterCreatePLSQLObject(t *testing.T) { + sqlText := "CREATE OR REPLACE PROCEDURE p AS\nBEGIN\n NULL;\nEND;\n/" + want := "CREATE OR REPLACE PROCEDURE p AS\nBEGIN\n NULL;\nEND;" + + if got := trimStatementSQL(sqlText); got != want { + t.Fatalf("trimStatementSQL() = %q, want %q", got, want) + } +} + +func TestTrimStatementSQLRemovesRegularStatementSemicolon(t *testing.T) { + if got := trimStatementSQL("SELECT 1 FROM DUAL;"); got != "SELECT 1 FROM DUAL" { + t.Fatalf("trimStatementSQL() = %q, want regular statement without semicolon", got) + } +} + +func TestOracleExplainPlanBindParamsIncludesNamedParameters(t *testing.T) { + sqlText := ` +SELECT * +FROM orders +WHERE id = :id + AND status = :status + AND parent_id = :id` + + want := []oracleBindParam{ + {Name: "id"}, + {Name: "status"}, + } + if got := oracleExplainPlanBindParams(sqlText); !reflect.DeepEqual(got, want) { + t.Fatalf("oracleExplainPlanBindParams() = %#v, want %#v", got, want) + } +} + +func TestOracleExplainPlanBindParamsSkipsQuotedTextAndComments(t *testing.T) { + sqlText := ` +SELECT ':literal' AS literal_value, + q'[not :q_param]' AS q_literal, + "COL:NAME" AS quoted_identifier +FROM orders +WHERE id = :id + -- ignored :comment_param + AND note <> 'escaped '' :text_param' + /* ignored :block_param */` + + want := []oracleBindParam{{Name: "id"}} + if got := oracleExplainPlanBindParams(sqlText); !reflect.DeepEqual(got, want) { + t.Fatalf("oracleExplainPlanBindParams() = %#v, want %#v", got, want) + } +} + +func TestOracleExplainPlanBindParamsIncludesPositionalParameters(t *testing.T) { + sqlText := "SELECT * FROM orders WHERE id = :1 AND status = :status" + + want := []oracleBindParam{ + {Name: "1", Positional: true}, + {Name: "status"}, + } + if got := oracleExplainPlanBindParams(sqlText); !reflect.DeepEqual(got, want) { + t.Fatalf("oracleExplainPlanBindParams() = %#v, want %#v", got, want) + } +} + +func TestOracleExplainPlanBindArgsUsesNamedArguments(t *testing.T) { + args := oracleExplainPlanBindArgs("SELECT * FROM orders WHERE id = :id") + + if len(args) != 1 { + t.Fatalf("expected one bind argument, got %#v", args) + } + named, ok := args[0].(sql.NamedArg) + if !ok { + t.Fatalf("expected sql.NamedArg, got %#v", args[0]) + } + if named.Name != "id" || named.Value != nil { + t.Fatalf("unexpected named bind argument: %#v", named) + } +} + +func protocolContract(t *testing.T) struct { + ProtocolVersion int `json:"protocolVersion"` + AllCapabilities []string `json:"allCapabilities"` +} { + t.Helper() + data, err := os.ReadFile("../../common/src/main/resources/agent-protocol-v1.json") + if err != nil { + t.Fatal(err) + } + var contract struct { + ProtocolVersion int `json:"protocolVersion"` + AllCapabilities []string `json:"allCapabilities"` + } + if err := json.Unmarshal(data, &contract); err != nil { + t.Fatal(err) + } + return contract +} + +func TestOracleColumnTypeDDL(t *testing.T) { + charLen := 64 + precision := 10 + scale := 2 + zeroScale := 0 + + tests := []struct { + name string + column columnInfo + want string + }{ + {name: "varchar", column: columnInfo{DataType: "VARCHAR2", CharacterMaximumLength: &charLen}, want: "VARCHAR2(64)"}, + {name: "number scale", column: columnInfo{DataType: "NUMBER", NumericPrecision: &precision, NumericScale: &scale}, want: "NUMBER(10,2)"}, + {name: "number zero scale", column: columnInfo{DataType: "NUMBER", NumericPrecision: &precision, NumericScale: &zeroScale}, want: "NUMBER(10)"}, + {name: "timestamp preserves precision", column: columnInfo{DataType: "TIMESTAMP(6)"}, want: "TIMESTAMP(6)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := oracleColumnTypeDDL(tt.column); got != tt.want { + t.Fatalf("oracleColumnTypeDDL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildDSNUsesConnectionStringWhenProvided(t *testing.T) { + dsn := buildDSN(connectParams{ConnectionString: "oracle://scott:tiger@db.example.com:1521/ORCLPDB1"}) + + if dsn != "oracle://scott:tiger@db.example.com:1521/ORCLPDB1" { + t.Fatalf("unexpected dsn: %s", dsn) + } +} + +func TestBuildDSNPreservesBastionUsernameAndEncodesCredentials(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Port: 1521, + Database: "XE", + Username: "9008888:reader", + Password: "dbx:pass", + }) + + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + password, _ := parsed.User.Password() + if parsed.User.Username() != "9008888:reader" || password != "dbx:pass" { + t.Fatalf("credentials should survive URL parsing, dsn=%s username=%q password=%q", dsn, parsed.User.Username(), password) + } + if !strings.HasPrefix(parsed.User.String(), "9008888%3Areader:") { + t.Fatalf("bastion username should be escaped without being quoted, dsn=%s", dsn) + } +} + +func TestBuildDSNEncodesColonInCredentialsFromJDBCServiceURL(t *testing.T) { + dsn := buildDSN(connectParams{ + Username: "9008888:reader", + Password: "dbx:pass", + ConnectionString: "jdbc:oracle:thin:@//db.example.com:1521/XE", + }) + + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + password, _ := parsed.User.Password() + if parsed.User.Username() != "9008888:reader" || password != "dbx:pass" { + t.Fatalf("credentials should survive JDBC URL conversion, dsn=%s username=%q password=%q", dsn, parsed.User.Username(), password) + } + if parsed.Host != "db.example.com:1521" || strings.TrimPrefix(parsed.Path, "/") != "XE" { + t.Fatalf("JDBC host/service should survive conversion, dsn=%s", dsn) + } +} + +func TestBuildDSNPreservesExplicitlyQuotedUsername(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Port: 1521, + Database: "XE", + Username: `"abc:def"`, + Password: "dbx:pass", + }) + + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + if parsed.User.Username() != `"abc:def"` { + t.Fatalf("explicitly quoted username should remain unchanged, dsn=%s username=%q", dsn, parsed.User.Username()) + } +} + +func TestBuildDSNUsesJdbcServiceHostAndPort(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "127.0.0.1", + Port: 11521, + Database: "ORCLPDB1", + Username: "scott", + Password: "tiger", + ConnectionString: "jdbc:oracle:thin:@//oracle.example.com:1521/ORCLPDB1", + }) + + if strings.Contains(strings.ToLower(dsn), "jdbc:") { + t.Fatalf("dsn should be go-ora format, got: %s", dsn) + } + if !strings.Contains(dsn, "oracle.example.com:1521") || !strings.Contains(dsn, "ORCLPDB1") { + t.Fatalf("dsn should use JDBC host/port/database fields, got: %s", dsn) + } +} + +func TestBuildDSNUsesRewrittenJdbcServiceHostAndPort(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "127.0.0.1", + Port: 11521, + Database: "ORCLPDB1", + Username: "scott", + Password: "tiger", + ConnectionString: "jdbc:oracle:thin:@//127.0.0.1:11521/ORCLPDB1", + }) + + if strings.Contains(strings.ToLower(dsn), "jdbc:") { + t.Fatalf("dsn should be go-ora format, got: %s", dsn) + } + if !strings.Contains(dsn, "127.0.0.1:11521") || !strings.Contains(dsn, "ORCLPDB1") { + t.Fatalf("dsn should use rewritten JDBC host/port/database fields, got: %s", dsn) + } +} + +func TestBuildDSNConvertsJdbcSID(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "127.0.0.1", + Port: 11521, + Database: "ORCL", + Username: "scott", + Password: "tiger", + ConnectionString: "jdbc:oracle:thin:@oracle.example.com:1521:ORCL", + }) + + if strings.Contains(strings.ToLower(dsn), "jdbc:") { + t.Fatalf("dsn should be go-ora format, got: %s", dsn) + } + upperDSN := strings.ToUpper(dsn) + if !strings.Contains(dsn, "oracle.example.com:1521") || !strings.Contains(upperDSN, "SID=ORCL") { + t.Fatalf("dsn should use JDBC host/port and SID option, got: %s", dsn) + } +} + +func TestBuildDSNConvertsJdbcDescriptor(t *testing.T) { + dsn := buildDSN(connectParams{ + Username: "scott", + Password: "tiger", + ConnectionString: "jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)))", + }) + + if !strings.HasPrefix(dsn, "oracle://scott:tiger@") { + t.Fatalf("descriptor should become go-ora url, got: %s", dsn) + } + if !strings.Contains(dsn, "connStr=") { + t.Fatalf("descriptor should be passed via connStr option, got: %s", dsn) + } +} + +func TestBuildDSNAddsSysDbaOption(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "127.0.0.1", + Port: 1521, + Database: "SYSDBA:ORCLPDB1", + Username: "sys", + Password: "secret", + SysDBA: true, + URLParams: "TRACE FILE=trace.log", + }) + + if strings.Contains(dsn, "SYSDBA:") { + t.Fatalf("dsn should strip SYSDBA prefix: %s", dsn) + } + if !strings.Contains(dsn, "ORCLPDB1") { + t.Fatalf("dsn should include service name: %s", dsn) + } + upperDSN := strings.ToUpper(dsn) + if !strings.Contains(upperDSN, "AUTH TYPE=SYSDBA") && + !strings.Contains(upperDSN, "AUTH+TYPE=SYSDBA") && + !strings.Contains(upperDSN, "AUTH%20TYPE=SYSDBA") { + t.Fatalf("dsn should include SYSDBA auth option: %s", dsn) + } +} + +func TestOracleGB18030ConverterRoundTrip(t *testing.T) { + converter := oracleGB18030Converter{} + input := "DBX \u4e2d\u6587 \U00020000" + + encoded := converter.Encode(input) + if string(encoded) == input { + t.Fatalf("GB18030 converter should encode non-ASCII text away from UTF-8 bytes") + } + if decoded := converter.Decode(encoded); decoded != input { + t.Fatalf("GB18030 round trip = %q, want %q", decoded, input) + } + if converter.GetLangID() != oracleCharsetZHS32GB18030 { + t.Fatalf("GB18030 converter lang id = %d, want %d", converter.GetLangID(), oracleCharsetZHS32GB18030) + } + if clone := converter.Clone(); clone.GetLangID() != oracleCharsetZHS32GB18030 { + t.Fatalf("GB18030 converter clone lang id = %d, want %d", clone.GetLangID(), oracleCharsetZHS32GB18030) + } +} + +func TestOracleStringConverterForUnsupportedCharsetError(t *testing.T) { + err := errors.New("the server use charset with id: 854 which is not supported by the driver") + converter, ok := oracleStringConverterForUnsupportedCharsetError(err) + if !ok { + t.Fatalf("expected GB18030 server charset error to have a converter") + } + if converter.GetLangID() != oracleCharsetZHS32GB18030 { + t.Fatalf("converter lang id = %d, want %d", converter.GetLangID(), oracleCharsetZHS32GB18030) + } + ncharsetErr := errors.New("the server use ncharset with id: 854 which is not supported by the driver") + if _, ok := oracleStringConverterForUnsupportedCharsetError(ncharsetErr); ok { + t.Fatalf("ncharset errors should not have a server charset converter") + } + otherCharsetErr := errors.New("the server use charset with id: 852 which is not supported by the driver") + if charsetID, ok := unsupportedOracleServerCharsetID(otherCharsetErr); !ok || charsetID != 852 { + t.Fatalf("other server charset should still be parsed, got id=%d ok=%v", charsetID, ok) + } + if _, ok := oracleStringConverterForUnsupportedCharsetError(otherCharsetErr); ok { + t.Fatalf("unknown charset ids should not get a guessed converter") + } +} + +func TestListDatabasesSQLUsesUserDictionaryInsteadOfObjectDictionary(t *testing.T) { + sqlText := strings.ToUpper(oracleListDatabasesSQL) + + if !strings.Contains(sqlText, "ALL_USERS") { + t.Fatalf("schema listing should query ALL_USERS, got: %s", oracleListDatabasesSQL) + } + if strings.Contains(sqlText, "ALL_TABLES") || strings.Contains(sqlText, "ALL_VIEWS") { + t.Fatalf("schema listing should not scan object dictionaries, got: %s", oracleListDatabasesSQL) + } +} + +func TestListDatabasesSQLCanApplyVisibleSchemaFilter(t *testing.T) { + sqlText, args := oracleListDatabasesSQLWithVisibleSchemas([]string{"APP", "REPORTING"}) + upperSQL := strings.ToUpper(sqlText) + + if !strings.Contains(upperSQL, "ALL_USERS") { + t.Fatalf("schema listing should query ALL_USERS, got: %s", sqlText) + } + if !strings.Contains(upperSQL, "USERNAME IN (:1,:2)") { + t.Fatalf("schema listing should apply visible schema filter, got: %s", sqlText) + } + if len(args) != 2 || args[0] != "APP" || args[1] != "REPORTING" { + t.Fatalf("visible schema args were not preserved: %#v", args) + } + if strings.Contains(upperSQL, "ALL_TABLES") || strings.Contains(upperSQL, "ALL_VIEWS") { + t.Fatalf("schema listing should not scan object dictionaries, got: %s", sqlText) + } +} + +func TestListTablesSQLUsesSplitDictionaryQuery(t *testing.T) { + sqlText := strings.ToUpper(oracleListTablesSQL) + + if !strings.Contains(sqlText, "ALL_TABLES") || !strings.Contains(sqlText, "ALL_OBJECTS") { + t.Fatalf("table listing should split tables and views, got: %s", oracleListTablesSQL) + } + if !strings.Contains(sqlText, "UNION ALL") { + t.Fatalf("table listing should union table and view metadata, got: %s", oracleListTablesSQL) + } + if strings.Contains(sqlText, "ALL_TAB_COMMENTS") { + t.Fatalf("table listing should not load comments during refresh, got: %s", oracleListTablesSQL) + } +} + +func TestListTablesQueryAppliesMetadataConstraints(t *testing.T) { + query := oracleListTablesQuery("APP", metadataListConstraints{ + Filter: "u_r", + Limit: 501, + Offset: 10, + ObjectTypes: []string{"view", "TABLE", "TABLE"}, + }) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE :3 ESCAPE '\\'") { + t.Fatalf("table listing should push filter predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "TABLE_TYPE IN (:4,:5)") { + t.Fatalf("table listing should push table type predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "ROWNUM <= :6") || !strings.Contains(sqlText, "DBX_RN > :7") { + t.Fatalf("table listing should use rownum pagination, got: %s", query.SQL) + } + if len(query.Args) != 7 { + t.Fatalf("unexpected args: %#v", query.Args) + } + if query.Args[0] != "APP" || query.Args[1] != "APP" || query.Args[2] != "%U%\\_%R%" || query.Args[3] != "TABLE" || query.Args[4] != "VIEW" || query.Args[5] != 511 || query.Args[6] != 10 { + t.Fatalf("constraints args were not normalized: %#v", query.Args) + } +} + +func TestListSessionUserTablesQueryUsesUserDictionary(t *testing.T) { + query := oracleListSessionUserTablesQuery(metadataListConstraints{ + Filter: "u_r", + Limit: 501, + Offset: 10, + ObjectTypes: []string{"view", "TABLE", "TABLE"}, + }) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "USER_TABLES") || !strings.Contains(sqlText, "USER_OBJECTS") { + t.Fatalf("session-user table listing should use USER_* dictionaries, got: %s", query.SQL) + } + if strings.Contains(sqlText, "ALL_TABLES") || strings.Contains(sqlText, "ALL_OBJECTS") { + t.Fatalf("session-user table listing should avoid ALL_* dictionaries, got: %s", query.SQL) + } + if strings.Contains(sqlText, "OWNER =") { + t.Fatalf("session-user table listing should not add owner predicates, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE :1 ESCAPE '\\'") { + t.Fatalf("table listing should push filter predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "TABLE_TYPE IN (:2,:3)") { + t.Fatalf("table listing should push table type predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "ROWNUM <= :4") || !strings.Contains(sqlText, "DBX_RN > :5") { + t.Fatalf("table listing should use rownum pagination, got: %s", query.SQL) + } + if len(query.Args) != 5 { + t.Fatalf("unexpected args: %#v", query.Args) + } + if query.Args[0] != "%U%\\_%R%" || query.Args[1] != "TABLE" || query.Args[2] != "VIEW" || query.Args[3] != 511 || query.Args[4] != 10 { + t.Fatalf("constraints args were not normalized: %#v", query.Args) + } +} + +func TestListObjectsSQLUsesSplitDictionaryQuery(t *testing.T) { + sqlText := strings.ToUpper(oracleListObjectsSQL) + + if !strings.Contains(sqlText, "ALL_TABLES") || !strings.Contains(sqlText, "ALL_OBJECTS") { + t.Fatalf("object listing should split tables from other objects, got: %s", oracleListObjectsSQL) + } + if !strings.Contains(sqlText, "UNION ALL") { + t.Fatalf("object listing should union object metadata, got: %s", oracleListObjectsSQL) + } + if strings.Contains(sqlText, "ALL_TAB_COMMENTS") { + t.Fatalf("object listing should not load comments during refresh, got: %s", oracleListObjectsSQL) + } + if !strings.Contains(sqlText, "'PACKAGE BODY'") || !strings.Contains(sqlText, "PACKAGE_BODY") { + t.Fatalf("object listing should include package bodies with normalized type, got: %s", oracleListObjectsSQL) + } +} + +func TestListObjectsQueryAppliesMetadataConstraints(t *testing.T) { + query := oracleListObjectsQuery("APP", metadataListConstraints{ + Filter: "pkg%", + Limit: 25, + ObjectTypes: []string{"FUNCTION", "package"}, + }) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE :3 ESCAPE '\\'") { + t.Fatalf("object listing should push filter predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "OBJECT_TYPE IN (:4,:5)") { + t.Fatalf("object listing should push object type predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "ROWNUM <= :6") || !strings.Contains(sqlText, "DBX_RN > :7") { + t.Fatalf("object listing should use rownum pagination, got: %s", query.SQL) + } + if len(query.Args) != 7 { + t.Fatalf("unexpected args: %#v", query.Args) + } + if query.Args[2] != "%P%K%G%\\%%" || query.Args[3] != "FUNCTION" || query.Args[4] != "PACKAGE" || query.Args[5] != 25 || query.Args[6] != 0 { + t.Fatalf("object constraints args were not normalized: %#v", query.Args) + } +} + +func TestListSessionUserObjectsQueryUsesUserDictionary(t *testing.T) { + query := oracleListSessionUserObjectsQuery(metadataListConstraints{ + Filter: "pkg%", + Limit: 25, + ObjectTypes: []string{"FUNCTION", "package"}, + }) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "USER_TABLES") || !strings.Contains(sqlText, "USER_OBJECTS") { + t.Fatalf("session-user object listing should use USER_* dictionaries, got: %s", query.SQL) + } + if strings.Contains(sqlText, "ALL_TABLES") || strings.Contains(sqlText, "ALL_OBJECTS") { + t.Fatalf("session-user object listing should avoid ALL_* dictionaries, got: %s", query.SQL) + } + if strings.Contains(sqlText, "OWNER =") { + t.Fatalf("session-user object listing should not add owner predicates, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE :1 ESCAPE '\\'") { + t.Fatalf("object listing should push filter predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "OBJECT_TYPE IN (:2,:3)") { + t.Fatalf("object listing should push object type predicate, got: %s", query.SQL) + } + if !strings.Contains(sqlText, "ROWNUM <= :4") || !strings.Contains(sqlText, "DBX_RN > :5") { + t.Fatalf("object listing should use rownum pagination, got: %s", query.SQL) + } + if len(query.Args) != 5 { + t.Fatalf("unexpected args: %#v", query.Args) + } + if query.Args[0] != "%P%K%G%\\%%" || query.Args[1] != "FUNCTION" || query.Args[2] != "PACKAGE" || query.Args[3] != 25 || query.Args[4] != 0 { + t.Fatalf("object constraints args were not normalized: %#v", query.Args) + } +} + +func TestOracleFuzzyLikePatternEscapesSpecialCharacters(t *testing.T) { + got := oracleFuzzyLikePattern(`a_%\b`) + want := `%a%\_%\%%\\%b%` + if got != want { + t.Fatalf("oracleFuzzyLikePattern() = %q, want %q", got, want) + } +} + +func TestIsOraclePGALimitError(t *testing.T) { + if !isOraclePGALimitError(errors.New("ORA-04036: PGA memory used by the instance exceeds PGA_AGGREGATE_LIMIT")) { + t.Fatal("expected ORA-04036 to be detected") + } + if isOraclePGALimitError(errors.New("ORA-00942: table or view does not exist")) { + t.Fatal("unexpected ORA-00942 match") + } +} + +func TestRewriteOracleXMLTypeSelectStar(t *testing.T) { + sqlText, err := rewriteOracleXMLTypeSelectSQL( + `SELECT * FROM TEST_LOBS`, + fakeOracleColumnLoader([]oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "XML_CONTENT", DataType: "XMLTYPE"}, + {Name: "TEST_NAME", DataType: "VARCHAR2"}, + }), + ) + if err != nil { + t.Fatal(err) + } + want := `SELECT "ID", XMLSERIALIZE(CONTENT "XML_CONTENT" AS CLOB) AS "XML_CONTENT", "TEST_NAME" FROM TEST_LOBS` + if sqlText != want { + t.Fatalf("rewriteOracleXMLTypeSelectSQL() = %s, want %s", sqlText, want) + } +} + +func TestRewriteOracleXMLTypeExplicitColumn(t *testing.T) { + sqlText, err := rewriteOracleXMLTypeSelectSQL( + `SELECT t.ID, t.XML_CONTENT AS xml_doc FROM TEST_LOBS t WHERE t.ID = 1`, + fakeOracleColumnLoader([]oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "XML_CONTENT", DataType: "SYS.XMLTYPE"}, + }), + ) + if err != nil { + t.Fatal(err) + } + want := `SELECT t.ID, XMLSERIALIZE(CONTENT t."XML_CONTENT" AS CLOB) AS xml_doc FROM TEST_LOBS t WHERE t.ID = 1` + if sqlText != want { + t.Fatalf("rewriteOracleXMLTypeSelectSQL() = %s, want %s", sqlText, want) + } +} + +func TestRewriteOracleXMLTypeNestedRownumQuery(t *testing.T) { + sqlText, err := rewriteOracleXMLTypeSelectSQL( + `SELECT * FROM (SELECT "ID", "XML_CONTENT" FROM "DBX"."TEST_LOBS") WHERE ROWNUM <= 100`, + fakeOracleColumnLoader([]oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "XML_CONTENT", DataType: "XMLTYPE"}, + }), + ) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(sqlText, `XMLSERIALIZE(CONTENT "XML_CONTENT" AS CLOB) AS "XML_CONTENT"`) { + t.Fatalf("expected nested XMLTYPE column to be serialized, got: %s", sqlText) + } +} + +func TestRewriteOracleXMLTypeSkipsJoins(t *testing.T) { + called := false + sqlText, err := rewriteOracleXMLTypeSelectSQL( + `SELECT * FROM TEST_LOBS l JOIN OTHER_TABLE o ON o.ID = l.ID`, + func(schema, table string) ([]oracleColumnMeta, error) { + called = true + return nil, nil + }, + ) + if err != nil { + t.Fatal(err) + } + if called { + t.Fatal("join query should not load table metadata") + } + if sqlText != `SELECT * FROM TEST_LOBS l JOIN OTHER_TABLE o ON o.ID = l.ID` { + t.Fatalf("join query should not be rewritten, got: %s", sqlText) + } +} + +func TestOracleColumnTypeNamesContainXMLType(t *testing.T) { + tests := []struct { + name string + typeNames []string + want bool + }{ + {name: "plain xmltype", typeNames: []string{"NUMBER", "XMLTYPE"}, want: true}, + {name: "qualified xmltype", typeNames: []string{"SYS.XMLTYPE"}, want: true}, + {name: "case and spaces", typeNames: []string{" varchar2 ", "sys.xmltype"}, want: true}, + {name: "ordinary columns", typeNames: []string{"NUMBER", "VARCHAR2", "DATE"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := oracleColumnTypeNamesContainXMLType(tt.typeNames); got != tt.want { + t.Fatalf("oracleColumnTypeNamesContainXMLType(%v) = %v, want %v", tt.typeNames, got, tt.want) + } + }) + } +} + +func fakeOracleColumnLoader(columns []oracleColumnMeta) oracleColumnMetaLoader { + return func(schema, table string) ([]oracleColumnMeta, error) { + if strings.ToUpper(table) != "TEST_LOBS" { + return nil, nil + } + return columns, nil + } +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/agents/drivers/oscar/build.gradle b/agents/drivers/oscar/build.gradle new file mode 100644 index 0000000..88d88c8 --- /dev/null +++ b/agents/drivers/oscar/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation files('libs/oscarJDBC8.jar') +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': '\u795e\u901a OSCAR', + 'Main-Class': 'com.dbx.agent.oscar.OscarAgent' + ) + } +} diff --git a/agents/drivers/oscar/libs/oscarJDBC8.jar b/agents/drivers/oscar/libs/oscarJDBC8.jar new file mode 100644 index 0000000..7d1a6c4 Binary files /dev/null and b/agents/drivers/oscar/libs/oscarJDBC8.jar differ diff --git a/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java b/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java new file mode 100644 index 0000000..ea247c8 --- /dev/null +++ b/agents/drivers/oscar/src/main/java/com/dbx/agent/oscar/OscarAgent.java @@ -0,0 +1,21 @@ +package com.dbx.agent.oscar; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class OscarAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile OSCAR_PROFILE = new JdbcAgentProfile( + "com.oscar.Driver", + "jdbc:oscar://{host}:{port}/{database}", + 2003 + ); + + public OscarAgent() { + super(OSCAR_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new OscarAgent()).run(); + } +} diff --git a/agents/drivers/oscar/src/test/java/com/dbx/agent/oscar/OscarAgentTest.java b/agents/drivers/oscar/src/test/java/com/dbx/agent/oscar/OscarAgentTest.java new file mode 100644 index 0000000..953e991 --- /dev/null +++ b/agents/drivers/oscar/src/test/java/com/dbx/agent/oscar/OscarAgentTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.oscar; + +import com.dbx.agent.JdbcAgentProfile; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class OscarAgentTest { + @Test + void declaresOscarJdbcProfile() { + JdbcAgentProfile profile = OscarAgent.OSCAR_PROFILE; + + Assertions.assertEquals("com.oscar.Driver", profile.getDriverClass()); + Assertions.assertEquals("jdbc:oscar://{host}:{port}/{database}", profile.getUrlTemplate()); + Assertions.assertEquals(2003, profile.getDefaultPort()); + } +} diff --git a/agents/drivers/saphana/build.gradle b/agents/drivers/saphana/build.gradle new file mode 100644 index 0000000..112aff5 --- /dev/null +++ b/agents/drivers/saphana/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.sap.cloud.db.jdbc:ngdbc:2.28.7' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'SAP HANA', 'Main-Class': 'com.dbx.agent.saphana.SapHanaAgent') + } +} diff --git a/agents/drivers/saphana/src/main/java/com/dbx/agent/saphana/SapHanaAgent.java b/agents/drivers/saphana/src/main/java/com/dbx/agent/saphana/SapHanaAgent.java new file mode 100644 index 0000000..269b2fd --- /dev/null +++ b/agents/drivers/saphana/src/main/java/com/dbx/agent/saphana/SapHanaAgent.java @@ -0,0 +1,26 @@ +package com.dbx.agent.saphana; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; +import java.util.Arrays; +import java.util.Collections; + +public final class SapHanaAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile SAPHANA_PROFILE = new JdbcAgentProfile( + "com.sap.db.jdbc.Driver", + "jdbc:sap://{host}:{port}/?databaseName={database}", + 30015, + false, + Collections.emptySet(), + Arrays.asList("COLUMN TABLE", "ROW TABLE", "TABLE", "VIEW") + ); + + public SapHanaAgent() { + super(SAPHANA_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new SapHanaAgent()).run(); + } +} diff --git a/agents/drivers/snowflake/build.gradle b/agents/drivers/snowflake/build.gradle new file mode 100644 index 0000000..7ba1819 --- /dev/null +++ b/agents/drivers/snowflake/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'net.snowflake:snowflake-jdbc:3.22.0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Snowflake', 'Main-Class': 'com.dbx.agent.snowflake.SnowflakeAgent') + } +} diff --git a/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java b/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java new file mode 100644 index 0000000..608ff57 --- /dev/null +++ b/agents/drivers/snowflake/src/main/java/com/dbx/agent/snowflake/SnowflakeAgent.java @@ -0,0 +1,381 @@ +package com.dbx.agent.snowflake; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public final class SnowflakeAgent extends AbstractJdbcAgent { + + @Override + protected String driverClass() { + return "net.snowflake.client.jdbc.SnowflakeDriver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString("name"))); + } + } + result.sort(Comparator.comparing(DatabaseInfo::getName)); + return result; + }); + } + + @Override + public List listSchemas() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW SCHEMAS")) { + while (rs.next()) { + result.add(rs.getString("name")); + } + } + Collections.sort(result); + return result; + }); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT TABLE_NAME, TABLE_TYPE " + + "FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_SCHEMA = ? " + + "ORDER BY TABLE_NAME"; + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("TABLE_NAME"), + normalizeTableType(rs.getString("TABLE_TYPE")), + null + )); + } + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(schema); + appendSnowflakeTableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints); + sql.append(" ORDER BY TABLE_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("TABLE_NAME"), + normalizeTableType(rs.getString("TABLE_TYPE")), + null + )); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + + String sql = "SELECT PROCEDURE_NAME, 'PROCEDURE' " + + "FROM INFORMATION_SCHEMA.PROCEDURES " + + "WHERE PROCEDURE_SCHEMA = ? " + + "ORDER BY PROCEDURE_NAME"; + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return queryConstrainedObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(schema); + appendSnowflakeTableTypePredicate(tableSql, args, constraints); + MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints); + branches.add(tableSql.toString()); + } + if (constraints.objectTypeAllowed("PROCEDURE")) { + StringBuilder procedureSql = new StringBuilder("SELECT PROCEDURE_NAME AS OBJECT_NAME, 'PROCEDURE' AS OBJECT_TYPE FROM INFORMATION_SCHEMA.PROCEDURES WHERE PROCEDURE_SCHEMA = ?"); + args.add(schema); + MetadataSqlSupport.appendNameFilter(procedureSql, args, "PROCEDURE_NAME", constraints); + branches.add(procedureSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'BASE TABLE' THEN 0 WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'MATERIALIZED VIEW' THEN 2 WHEN 'PROCEDURE' THEN 3 ELSE 9 END, OBJECT_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo( + rs.getString("OBJECT_NAME"), + normalizeTableType(rs.getString("OBJECT_TYPE")), + schema, + null + )); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String ddlType = objectType.toUpperCase(Locale.ROOT); + String sql = "SELECT GET_DDL('" + ddlType + "', '\"" + schema + "\".\"" + name + "\"')"; + String source = ""; + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + if (rs.next()) { + String value = rs.getString(1); + source = value == null ? "" : value; + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Connection conn = requireConnected(); + List primaryKeys = new ArrayList<>(); + try (java.sql.Statement stmt = conn.createStatement()) { + String qualifiedTable = JdbcIdentifiers.INSTANCE.doubleQuote(schema) + "." + JdbcIdentifiers.INSTANCE.doubleQuote(table); + try (ResultSet rs = stmt.executeQuery("SHOW PRIMARY KEYS IN TABLE " + qualifiedTable)) { + while (rs.next()) { + primaryKeys.add(rs.getString("column_name")); + } + } + } catch (Exception ignored) { + // Snowflake may not return primary key metadata for every object. + } + + List result = new ArrayList<>(); + String sql = "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + + "NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, COMMENT " + + "FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? " + + "ORDER BY ORDINAL_POSITION"; + try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String colName = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + colName, + rs.getString("DATA_TYPE"), + "YES".equals(rs.getString("IS_NULLABLE")), + rs.getString("COLUMN_DEFAULT"), + primaryKeys.contains(colName), + null, + blankToNull(rs.getString("COMMENT")), + intOrNull(rs, "NUMERIC_PRECISION"), + intOrNull(rs, "NUMERIC_SCALE"), + intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + try (java.sql.Statement stmt = requireConnected().createStatement()) { + String qualifiedTable = JdbcIdentifiers.INSTANCE.doubleQuote(schema) + "." + JdbcIdentifiers.INSTANCE.doubleQuote(table); + try (ResultSet rs = stmt.executeQuery("SHOW IMPORTED KEYS IN TABLE " + qualifiedTable)) { + List result = new ArrayList<>(); + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("fk_name"), + rs.getString("fk_column_name"), + rs.getString("pk_table_name"), + rs.getString("pk_column_name") + )); + } + return result; + } + } catch (Exception ignored) { + return Collections.emptyList(); + } + }); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE SCHEMA " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + private static String buildUrl(ConnectParams params) { + return "jdbc:snowflake://" + params.getHost() + ":" + params.getPort() + "/?db=" + params.getDatabase(); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() || constraints.objectTypeAllowed("PROCEDURE"); + } + + private static void appendSnowflakeTableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (constraints.tableTypeAllowed("MATERIALIZED_VIEW")) { + types.add("MATERIALIZED VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND TABLE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static String normalizeTableType(String tableType) { + if ("BASE TABLE".equals(tableType)) { + return "TABLE"; + } + if ("MATERIALIZED VIEW".equals(tableType)) { + return "MATERIALIZED_VIEW"; + } + return tableType; + } + + private static String blankToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + private static Integer intOrNull(ResultSet rs, String column) throws java.sql.SQLException { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + public static void main(String[] args) { + new JsonRpcServer(new SnowflakeAgent()).run(); + } +} diff --git a/agents/drivers/snowflake/src/test/java/com/dbx/agent/snowflake/SnowflakeAgentMetadataTest.java b/agents/drivers/snowflake/src/test/java/com/dbx/agent/snowflake/SnowflakeAgentMetadataTest.java new file mode 100644 index 0000000..8708a7f --- /dev/null +++ b/agents/drivers/snowflake/src/test/java/com/dbx/agent/snowflake/SnowflakeAgentMetadataTest.java @@ -0,0 +1,59 @@ +package com.dbx.agent.snowflake; + +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.TestSupport; +import com.dbx.agent.test.JdbcAgentFake; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class SnowflakeAgentMetadataTest { + @Test + void quotesSchemaAndTableIdentifiersInKeyMetadataSql() { + SnowflakeAgent agent = new SnowflakeAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.getColumns("bad\"schema", "bad\"table"); + agent.listForeignKeys("bad\"schema", "bad\"table"); + + Assertions.assertTrue( + JdbcMetadataSqlFake.statements.contains( + "SHOW PRIMARY KEYS IN TABLE \"bad\"\"schema\".\"bad\"\"table\"" + ) + ); + Assertions.assertTrue( + JdbcMetadataSqlFake.statements.contains( + "SHOW IMPORTED KEYS IN TABLE \"bad\"\"schema\".\"bad\"\"table\"" + ) + ); + } + + @Test + void constrainedTableMetadataPushesFilterTypesAndPaging() { + SnowflakeAgent agent = new SnowflakeAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("PUBLIC", new MetadataListConstraints("ord", 25, 50, List.of("TABLE", "VIEW"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM INFORMATION_SCHEMA.TABLES"), sql); + Assertions.assertTrue(sql.contains("TABLE_TYPE IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("UPPER(TABLE_NAME) LIKE ? ESCAPE '\\\\'"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql); + } + + @Test + void constrainedObjectMetadataPushesProcedureSearchAndPaging() { + SnowflakeAgent agent = new SnowflakeAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listObjects("PUBLIC", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM INFORMATION_SCHEMA.PROCEDURES"), sql); + Assertions.assertTrue(sql.contains("UPPER(PROCEDURE_NAME) LIKE ? ESCAPE '\\\\'"), sql); + Assertions.assertTrue(sql.contains("ORDER BY CASE OBJECT_TYPE"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 10"), sql); + } +} diff --git a/agents/drivers/snowflake/src/test/java/com/dbx/agent/snowflake/SnowflakeAgentTest.java b/agents/drivers/snowflake/src/test/java/com/dbx/agent/snowflake/SnowflakeAgentTest.java new file mode 100644 index 0000000..11af6e4 --- /dev/null +++ b/agents/drivers/snowflake/src/test/java/com/dbx/agent/snowflake/SnowflakeAgentTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.snowflake; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; + +class SnowflakeAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new SnowflakeAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } +} diff --git a/agents/drivers/spark/build.gradle b/agents/drivers/spark/build.gradle new file mode 100644 index 0000000..868be72 --- /dev/null +++ b/agents/drivers/spark/build.gradle @@ -0,0 +1,11 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.apache.hive:hive-jdbc:4.0.1:standalone' + implementation 'org.slf4j:slf4j-nop:1.7.30' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Apache Spark', 'Main-Class': 'com.dbx.agent.spark.SparkAgent') + } +} diff --git a/agents/drivers/spark/src/main/java/com/dbx/agent/spark/SparkAgent.java b/agents/drivers/spark/src/main/java/com/dbx/agent/spark/SparkAgent.java new file mode 100644 index 0000000..bbe1570 --- /dev/null +++ b/agents/drivers/spark/src/main/java/com/dbx/agent/spark/SparkAgent.java @@ -0,0 +1,341 @@ +package com.dbx.agent.spark; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +// Spark Thrift Server speaks the HiveServer2 protocol, so the Spark agent reuses +// the Hive JDBC driver and mirrors the Hive agent's metadata queries (SHOW +// DATABASES / SHOW TABLES / DESCRIBE), which Spark SQL supports as well. +// +// Spark 3.4+ supports multiple catalogs (Paimon, Lance, Iceberg, ...). When the +// user supplies `catalog=` in url_params, the agent switches to that catalog +// after connecting and uses `SHOW TABLES IN ` for listing — which works +// uniformly for catalogs that have databases (Paimon) and those that expose tables +// at the catalog root with no databases (Lance). Mirrors the StarRocks catalog flow. +public final class SparkAgent extends AbstractJdbcAgent { + private String configuredCatalog; + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + // Hive JDBC fetches rows via Thrift FetchResults in batches controlled + // by fetchSize. A large fetchSize can fail with "Error retrieving next + // row" when the result contains large binary/struct columns. Use a + // small fetchSize (50) so each Thrift batch stays within limits. + return super.executeQuery(sql, schema, new ExecuteQueryOptions( + options.getMaxRows(), + 50, + options.getTimeoutSecs() + )); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + return super.executeQueryPage(sql, schema, withSmallFetchSize(options)); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return super.startTableRead(sql, schema, withSmallFetchSize(options)); + } + + private static QueryPageOptions withSmallFetchSize(QueryPageOptions options) { + return new QueryPageOptions( + options.getPageSize(), 50, options.getMaxRows(), options.getTimeoutSecs() + ); + } + + @Override + protected String driverClass() { + return "org.apache.hive.jdbc.HiveDriver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return "jdbc:hive2://" + params.getHost() + ":" + params.getPort() + "/"; + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) throws Exception { + String catalog = extractCatalogParam(params.getUrl_params()); + configuredCatalog = catalog; + if (catalog != null && !catalog.isEmpty()) { + try (java.sql.Statement stmt = connection.createStatement()) { + stmt.execute("USE " + JdbcIdentifiers.INSTANCE.backtick(catalog)); + } + } + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + // Catalogs like Lance expose tables at the catalog root with no + // databases — SHOW DATABASES returns nothing. Return the catalog + // name itself as the database node so the sidebar can drill in. + if (result.isEmpty() && configuredCatalog != null && !configuredCatalog.isEmpty()) { + result.add(new DatabaseInfo(configuredCatalog)); + } + result.sort(Comparator.comparing(DatabaseInfo::getName)); + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = buildShowTablesSql(schema); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String name = readTableName(rs); + if (name != null && !name.isEmpty()) { + result.add(new TableInfo(name, "TABLE", null)); + } + } + } + result.sort(Comparator.comparing(TableInfo::getName)); + return result; + }); + } + + // With a catalog configured, use `SHOW TABLES IN [.]` which + // works for both catalog-root tables (Lance) and catalog.schema tables (Paimon). + // Without a catalog, fall back to the Hive-style `USE ` + SHOW TABLES. + private String buildShowTablesSql(String schema) throws Exception { + if (configuredCatalog != null && !configuredCatalog.isEmpty()) { + String target = JdbcIdentifiers.INSTANCE.backtick(configuredCatalog); + if (schema != null && !schema.isEmpty() && !schema.equals(configuredCatalog)) { + target += "." + JdbcIdentifiers.INSTANCE.backtick(schema); + } + return "SHOW TABLES IN " + target; + } + useSchema(schema); + return "SHOW TABLES"; + } + + // `SHOW TABLES` (Hive style) returns a single tabName column, while + // `SHOW TABLES IN ` (Spark SQL) returns (database, tableName, + // isTemporary). Pick tableName from whichever column it lives in. + private static String readTableName(ResultSet rs) throws Exception { + int columns = rs.getMetaData().getColumnCount(); + return columns > 1 ? rs.getString(2) : rs.getString(1); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + try { + return getColumnsFromDescribe(schema, table); + } catch (Exception ignored) { + return getColumnsFromMetadata(requireConnected(), schema, table); + } + }); + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + // When a catalog is configured, qualify the USE statement with the + // catalog so switching schemas does not reset the catalog back to the + // default (spark_catalog). + if (configuredCatalog != null && !configuredCatalog.isEmpty()) { + if (schema == null || schema.isEmpty() || schema.equals(configuredCatalog)) { + return "USE " + JdbcIdentifiers.INSTANCE.backtick(configuredCatalog); + } + return "USE " + JdbcIdentifiers.INSTANCE.backtick(configuredCatalog) + + "." + JdbcIdentifiers.INSTANCE.backtick(schema); + } + return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema); + } + + private static String extractCatalogParam(String urlParams) { + if (urlParams == null || urlParams.isEmpty()) { + return null; + } + for (String segment : urlParams.split("&")) { + int eq = segment.indexOf('='); + if (eq <= 0) { + continue; + } + if (segment.substring(0, eq).trim().equalsIgnoreCase("catalog")) { + String value = segment.substring(eq + 1).trim(); + return value.isEmpty() ? null : value; + } + } + return null; + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + private void useSchema(String schema) throws Exception { + try (java.sql.Statement stmt = requireConnected().createStatement()) { + stmt.execute(setSchemaSQL(schema)); + } + } + + private List getColumnsFromDescribe(String schema, String table) throws Exception { + List result = new ArrayList<>(); + // Use catalog-qualified DESCRIBE when a catalog is configured, avoiding + // USE which would generate USE `catalog`.`catalog` when the + // sidebar passes the catalog name as the schema (Lance case). + String describeTarget; + if (configuredCatalog != null && !configuredCatalog.isEmpty()) { + describeTarget = JdbcIdentifiers.INSTANCE.backtick(configuredCatalog); + if (schema != null && !schema.isEmpty() && !schema.equals(configuredCatalog)) { + describeTarget += "." + JdbcIdentifiers.INSTANCE.backtick(schema); + } + describeTarget += "." + JdbcIdentifiers.INSTANCE.backtick(table); + } else { + useSchema(schema); + describeTarget = JdbcIdentifiers.INSTANCE.backtick(table); + } + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("DESCRIBE " + describeTarget)) { + while (rs.next()) { + String colName = trimToNull(rs.getString(1)); + if (colName == null || colName.startsWith("#")) { + continue; + } + result.add(new ColumnInfo( + colName, + trimToEmpty(rs.getString(2)), + true, + null, + false, + null, + optionalComment(rs), + null, + null, + null + )); + } + } + return result; + } + + private static List getColumnsFromMetadata(Connection conn, String schema, String table) throws Exception { + List result = new ArrayList<>(); + DatabaseMetaData meta = conn.getMetaData(); + try (ResultSet rs = meta.getColumns(null, trimToNull(schema), table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + if (trimToNull(name) == null) { + continue; + } + result.add(new ColumnInfo( + name, + trimToEmpty(rs.getString("TYPE_NAME")), + rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + rs.getString("COLUMN_DEF"), + false, + null, + rs.getString("REMARKS"), + intOrNull(rs, "COLUMN_SIZE"), + intOrNull(rs, "DECIMAL_DIGITS"), + characterLength(rs) + )); + } + } + return result; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static Integer characterLength(ResultSet rs) throws Exception { + String typeName = rs.getString("TYPE_NAME"); + if (typeName == null) { + return null; + } + String normalized = typeName.toLowerCase(Locale.ROOT); + if (!normalized.contains("char") && !normalized.contains("text") && !normalized.contains("string")) { + return null; + } + return intOrNull(rs, "COLUMN_SIZE"); + } + + private static String optionalComment(ResultSet rs) { + return unchecked(() -> { + try { + String comment = trimToNull(rs.getString(3)); + return comment; + } catch (Exception e) { + return null; + } + }); + } + + public static void main(String[] args) { + new JsonRpcServer(new SparkAgent()).run(); + } +} diff --git a/agents/drivers/sqlserver-legacy/build.gradle b/agents/drivers/sqlserver-legacy/build.gradle new file mode 100644 index 0000000..6930022 --- /dev/null +++ b/agents/drivers/sqlserver-legacy/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation 'com.microsoft.sqlserver:mssql-jdbc:13.2.0.jre11' +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'SQL Server legacy compatibility component', + 'Main-Class': 'com.dbx.agent.sqlserverlegacy.SqlServerLegacyAgent' + ) + } +} diff --git a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java new file mode 100644 index 0000000..eac8b40 --- /dev/null +++ b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java @@ -0,0 +1,264 @@ +package com.dbx.agent.sqlserverlegacy; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +import java.security.Security; +import java.sql.Connection; +import java.sql.Driver; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { + private static final String TLS_DISABLED_ALGORITHMS_KEY = "jdk.tls.disabledAlgorithms"; + private static final Set LEGACY_TLS_ALGORITHMS_TO_ALLOW = Set.of( + "TLSV1", + "TLSV1.1", + "DTLSV1.0", + "3DES_EDE_CBC", + "RC4", + "DES", + "MD5WITHRSA", + "DH KEYSIZE < 1024", + "RSA KEYSIZE < 1024" + ); + private static final Set INTERNAL_URL_PARAMS = Set.of( + "SQLSERVERENCRYPTION", + "ENCRYPT", + "TRUSTSERVERCERTIFICATE", + "SSLPROTOCOL" + ); + private static final JdbcAgentProfile PROFILE = new JdbcAgentProfile( + "com.microsoft.sqlserver.jdbc.SQLServerDriver", + "jdbc:sqlserver://{host}:{port};databaseName={database};", + 1433, + true, + Set.of("INFORMATION_SCHEMA", "SYS"), + Arrays.asList("TABLE", "VIEW", "SYSTEM TABLE") + ); + + public SqlServerLegacyAgent() { + super(PROFILE); + // AbstractJdbcAgent loads the JDBC driver before building the URL, so relax + // the legacy TLS policy here before the driver can initialize JSSE. + enableLegacyTlsAlgorithms(); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return legacyTlsUrl(params); + } + + @Override + protected Connection openConnection(ConnectParams params) throws Exception { + try { + return super.openConnection(params); + } catch (SQLException error) { + throw withLegacyTlsDiagnostics(error); + } + } + + static String legacyTlsUrl(ConnectParams params) { + Map properties = baseConnectionProperties(params); + properties.put("encrypt", "true"); + properties.put("trustServerCertificate", "true"); + properties.put("sslProtocol", "TLSv1"); + return appendProperties(baseJdbcUrl(params), properties); + } + + static String relaxedDisabledAlgorithms(String current) { + if (current == null || current.trim().isEmpty()) { + return ""; + } + + List kept = new ArrayList<>(); + for (String rawPart : current.split(",")) { + String part = rawPart.trim(); + if (part.isEmpty()) { + continue; + } + if (!LEGACY_TLS_ALGORITHMS_TO_ALLOW.contains(part.toUpperCase(Locale.ROOT))) { + kept.add(part); + } + } + return String.join(", ", kept); + } + + static String legacyTlsDiagnostics() { + String disabledAlgorithms = Security.getProperty(TLS_DISABLED_ALGORITHMS_KEY); + return "DBX SQL Server legacy TLS diagnostics: java=" + System.getProperty("java.version", "unknown") + + ", javaVendor=" + System.getProperty("java.vendor", "unknown") + + ", jdbc=" + jdbcDriverVersion() + + ", sslProtocol=TLSv1" + + ", tlsV1Disabled=" + isDisabled(disabledAlgorithms, "TLSV1") + + ", 3desDisabled=" + isDisabled(disabledAlgorithms, "3DES_EDE_CBC") + + ", rc4Disabled=" + isDisabled(disabledAlgorithms, "RC4"); + } + + static SQLException withLegacyTlsDiagnostics(SQLException error) { + String message = error.getMessage() == null ? error.toString() : error.getMessage(); + return new SQLException( + message + "\n\n" + legacyTlsDiagnostics(), + error.getSQLState(), + error.getErrorCode(), + error + ); + } + + private static boolean isDisabled(String disabledAlgorithms, String algorithm) { + if (disabledAlgorithms == null || disabledAlgorithms.trim().isEmpty()) { + return false; + } + for (String rawPart : disabledAlgorithms.split(",")) { + if (algorithm.equals(rawPart.trim().toUpperCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + private static String jdbcDriverVersion() { + try { + Driver driver = DriverManager.getDriver("jdbc:sqlserver://localhost"); + Package driverPackage = driver.getClass().getPackage(); + String implementationVersion = driverPackage == null ? null : driverPackage.getImplementationVersion(); + if (implementationVersion != null && !implementationVersion.trim().isEmpty()) { + return implementationVersion; + } + return driver.getMajorVersion() + "." + driver.getMinorVersion(); + } catch (SQLException ignored) { + return "unknown"; + } + } + + private static void enableLegacyTlsAlgorithms() { + String current = Security.getProperty(TLS_DISABLED_ALGORITHMS_KEY); + String relaxed = relaxedDisabledAlgorithms(current); + if (!Objects.equals(current, relaxed)) { + Security.setProperty(TLS_DISABLED_ALGORITHMS_KEY, relaxed); + } + } + + private static String baseJdbcUrl(ConnectParams params) { + String connectionString = params.getConnection_string(); + if (connectionString != null && !connectionString.trim().isEmpty()) { + return sanitizeSqlServerUrl(connectionString.trim()); + } + + String host = normalizedSqlServerHost(params.getHost()); + StringBuilder url = new StringBuilder("jdbc:sqlserver://") + .append(host); + if (!usesNamedInstance(host)) { + int port = params.getPort() > 0 ? params.getPort() : PROFILE.getDefaultPort(); + url.append(":").append(port); + } + if (params.getDatabase() != null && !params.getDatabase().trim().isEmpty()) { + url.append(";databaseName=").append(params.getDatabase().trim()); + } + return trimSqlServerUrl(url.toString()); + } + + private static String normalizedSqlServerHost(String value) { + String host = value == null ? "" : value.trim(); + int separator = host.indexOf('\\'); + if (separator <= 0 || separator >= host.length() - 1) { + return host; + } + + String server = host.substring(0, separator).trim(); + String instance = host.substring(separator + 1).trim(); + if (server.isEmpty() || instance.isEmpty()) { + return host; + } + return server + "\\" + instance; + } + + private static boolean usesNamedInstance(String host) { + int separator = host.indexOf('\\'); + return separator > 0 && separator < host.length() - 1; + } + + private static String sanitizeSqlServerUrl(String value) { + String trimmed = trimSqlServerUrl(value); + String[] parts = trimmed.split(";"); + if (parts.length <= 1) { + return trimmed; + } + StringBuilder result = new StringBuilder(parts[0].trim()); + for (int i = 1; i < parts.length; i++) { + String part = parts[i].trim(); + if (part.isEmpty()) { + continue; + } + int separator = part.indexOf('='); + if (separator <= 0) { + result.append(";").append(part); + continue; + } + String key = part.substring(0, separator).trim(); + if (!INTERNAL_URL_PARAMS.contains(key.toUpperCase(Locale.ROOT))) { + result.append(";").append(part); + } + } + return result.toString(); + } + + private static Map baseConnectionProperties(ConnectParams params) { + Map properties = new LinkedHashMap<>(); + String urlParams = params.getUrl_params(); + if (urlParams == null || urlParams.trim().isEmpty()) { + return properties; + } + + for (String pair : urlParams.trim().split("[&;]")) { + String value = pair.trim(); + while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) { + value = value.substring(1).trim(); + } + if (value.isEmpty()) { + continue; + } + int separator = value.indexOf('='); + if (separator <= 0) { + continue; + } + String key = value.substring(0, separator).trim(); + String normalizedKey = key.toUpperCase(Locale.ROOT); + if (key.isEmpty() || INTERNAL_URL_PARAMS.contains(normalizedKey)) { + continue; + } + properties.put(key, value.substring(separator + 1).trim()); + } + return properties; + } + + private static String appendProperties(String base, Map properties) { + StringBuilder url = new StringBuilder(trimSqlServerUrl(base)); + for (Map.Entry entry : properties.entrySet()) { + url.append(";").append(entry.getKey()).append("=").append(entry.getValue()); + } + return url.toString(); + } + + private static String trimSqlServerUrl(String value) { + String trimmed = value.trim(); + while (trimmed.endsWith(";") || trimmed.endsWith("&") || trimmed.endsWith("?")) { + trimmed = trimmed.substring(0, trimmed.length() - 1).trim(); + } + return trimmed; + } + + public static void main(String[] args) throws Exception { + new JsonRpcServer(new SqlServerLegacyAgent()).run(); + } +} diff --git a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java new file mode 100644 index 0000000..2f3249f --- /dev/null +++ b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java @@ -0,0 +1,111 @@ +package com.dbx.agent.sqlserverlegacy; + +import com.dbx.agent.ConnectParams; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.security.Security; +import java.sql.SQLException; + +class SqlServerLegacyAgentTest { + @Test + void constructorRelaxesLegacyTlsPolicyBeforeDriverLoading() { + String key = "jdk.tls.disabledAlgorithms"; + String original = Security.getProperty(key); + try { + Security.setProperty(key, "TLSv1, TLSv1.1, 3DES_EDE_CBC, EC keySize < 224"); + + new SqlServerLegacyAgent(); + + Assertions.assertEquals("EC keySize < 224", Security.getProperty(key)); + String diagnostics = SqlServerLegacyAgent.legacyTlsDiagnostics(); + Assertions.assertTrue(diagnostics.contains("sslProtocol=TLSv1")); + Assertions.assertTrue(diagnostics.contains("tlsV1Disabled=false")); + Assertions.assertTrue(diagnostics.contains("3desDisabled=false")); + Assertions.assertTrue(diagnostics.contains("rc4Disabled=false")); + } finally { + Security.setProperty(key, original == null ? "" : original); + } + } + + @Test + void connectionErrorsPreserveDetailsAndIncludeRuntimeDiagnostics() { + SQLException original = new SQLException("TLS handshake failed", "08001", 1234); + + SQLException error = SqlServerLegacyAgent.withLegacyTlsDiagnostics(original); + + Assertions.assertEquals("08001", error.getSQLState()); + Assertions.assertEquals(1234, error.getErrorCode()); + Assertions.assertSame(original, error.getCause()); + Assertions.assertTrue(error.getMessage().contains("TLS handshake failed")); + Assertions.assertTrue(error.getMessage().contains("DBX SQL Server legacy TLS diagnostics:")); + } + + @Test + void legacyTlsUrlUsesSqlServerTlsV1Properties() { + ConnectParams params = new ConnectParams( + "db.example.com", + 14330, + "appdb", + "sa", + "secret", + "applicationName=dbx;sqlserverEncryption=disabled;encrypt=false;trustServerCertificate=false;sslProtocol=TLSv1.2", + "", + false + ); + + Assertions.assertEquals( + "jdbc:sqlserver://db.example.com:14330;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + SqlServerLegacyAgent.legacyTlsUrl(params) + ); + } + + @Test + void legacyTlsUrlKeepsNamedInstanceWithoutPort() { + ConnectParams params = new ConnectParams( + "db.example.com\\SQLEXPRESS", + 1433, + "appdb", + "sa", + "secret", + "applicationName=dbx", + "", + false + ); + + Assertions.assertEquals( + "jdbc:sqlserver://db.example.com\\SQLEXPRESS;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + SqlServerLegacyAgent.legacyTlsUrl(params) + ); + } + + @Test + void legacyTlsUrlNormalizesExplicitConnectionString() { + ConnectParams params = new ConnectParams( + "ignored", + 0, + "", + "sa", + "secret", + "applicationName=dbx", + "jdbc:sqlserver://db.example.com:1433;encrypt=false;databaseName=custom;trustServerCertificate=false;sslProtocol=TLSv1.2;", + false + ); + + Assertions.assertEquals( + "jdbc:sqlserver://db.example.com:1433;databaseName=custom;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + SqlServerLegacyAgent.legacyTlsUrl(params) + ); + } + + @Test + void relaxedDisabledAlgorithmsRemovesOnlyLegacyTlsEntries() { + String current = + "SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL"; + + Assertions.assertEquals( + "SSLv3, EC keySize < 224, anon, NULL", + SqlServerLegacyAgent.relaxedDisabledAlgorithms(current) + ); + } +} diff --git a/agents/drivers/sundb/build.gradle b/agents/drivers/sundb/build.gradle new file mode 100644 index 0000000..ef14a73 --- /dev/null +++ b/agents/drivers/sundb/build.gradle @@ -0,0 +1,13 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'SunDB', + 'Agent-External-Driver': 'true', + 'Main-Class': 'com.dbx.agent.sundb.SundbAgent' + ) + } +} diff --git a/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java b/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java new file mode 100644 index 0000000..e36620f --- /dev/null +++ b/agents/drivers/sundb/src/main/java/com/dbx/agent/sundb/SundbAgent.java @@ -0,0 +1,530 @@ +package com.dbx.agent.sundb; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class SundbAgent extends BaseDatabaseAgent { + private Connection connection; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + Class.forName("com.sundb.jdbc.SundbDriver"); + String url = buildUrl(params); + connection = DriverManager.getConnection(url, params.getUsername(), params.getPassword()); + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + Class.forName("com.sundb.jdbc.SundbDriver"); + String url = buildUrl(params); + try (Connection conn = DriverManager.getConnection(url, params.getUsername(), params.getPassword())) { + return conn.isValid(5); + } + }); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (var stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + List result = new ArrayList<>(); + for (DatabaseInfo database : listDatabases()) { + result.add(database.getName()); + } + return result; + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String tableType = rs.getString("TABLE_TYPE"); + if ("BASE TABLE".equals(tableType)) { + tableType = "TABLE"; + } + result.add(new TableInfo(rs.getString("TABLE_NAME"), tableType, null)); + } + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(schema); + appendInformationSchemaTableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints); + sql.append(" ORDER BY TABLE_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (var stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo(rs.getString("TABLE_NAME"), normalizeTableType(rs.getString("TABLE_TYPE")), null)); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List listObjects(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + + try (var stmt = requireConnected().prepareStatement( + "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME" + )) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), rs.getString(2), schema, null)); + } + } + } + return result; + }); + } + + @Override + public List listObjects(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listObjects(schema); + } + if (!includesSupportedObjects(normalized)) { + return List.of(); + } + try { + return queryConstrainedObjects(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterObjects(listObjects(schema)); + } + } + + private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List branches = new ArrayList<>(); + List args = new ArrayList<>(); + if (constraints.includesTableLikeTypes()) { + StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?"); + args.add(schema); + appendInformationSchemaTableTypePredicate(tableSql, args, constraints); + MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints); + branches.add(tableSql.toString()); + } + if (constraints.objectTypeAllowed("PROCEDURE") || constraints.objectTypeAllowed("FUNCTION")) { + StringBuilder routineSql = new StringBuilder("SELECT ROUTINE_NAME AS OBJECT_NAME, ROUTINE_TYPE AS OBJECT_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?"); + args.add(schema); + appendRoutineTypePredicate(routineSql, args, constraints); + MetadataSqlSupport.appendNameFilter(routineSql, args, "ROUTINE_NAME", constraints); + branches.add(routineSql.toString()); + } + if (branches.isEmpty()) { + return List.of(); + } + StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE FROM (") + .append(String.join(" UNION ALL ", branches)) + .append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'BASE TABLE' THEN 0 WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 WHEN 'FUNCTION' THEN 3 ELSE 9 END, OBJECT_NAME"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (var stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ObjectInfo(rs.getString(1), normalizeTableType(rs.getString(2)), schema, null)); + } + } + } + return constraints.withoutPaging().filterObjects(result); + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String sql = switch (objectType.toUpperCase(Locale.ROOT)) { + case "VIEW" -> "SHOW CREATE VIEW `" + name.replace("`", "``") + "`"; + case "PROCEDURE" -> "SHOW CREATE PROCEDURE `" + name.replace("`", "``") + "`"; + case "FUNCTION" -> "SHOW CREATE FUNCTION `" + name.replace("`", "``") + "`"; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + + String source = ""; + try (var stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + if (rs.next()) { + int index = "VIEW".equals(objectType.toUpperCase(Locale.ROOT)) ? 1 : 2; + String value = rs.getString(index + 1); + source = value == null ? "" : value; + } + } + return new ObjectSource(name, objectType, schema, source); + }); + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + Set primaryKeys = new HashSet<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + primaryKeys.add(rs.getString("COLUMN_NAME")); + } + } + } + + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, + NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String columnName = rs.getString("COLUMN_NAME"); + result.add(new ColumnInfo( + columnName, + rs.getString("COLUMN_TYPE"), + "YES".equals(rs.getString("IS_NULLABLE")), + rs.getString("COLUMN_DEFAULT"), + primaryKeys.contains(columnName), + rs.getString("EXTRA"), + emptyToNull(rs.getString("COLUMN_COMMENT")), + integerOrNull(rs, "NUMERIC_PRECISION"), + integerOrNull(rs, "NUMERIC_SCALE"), + numberToIntOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") + )); + } + } + } + return result; + }); + } + + @Override + public List listIndexes(String schema, String table) { + return unchecked(() -> { + try (var stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery( + "SHOW INDEX FROM " + JdbcIdentifiers.INSTANCE.backtick(table) + + " FROM " + JdbcIdentifiers.INSTANCE.backtick(schema) + )) { + Map> indexMap = new LinkedHashMap<>(); + Map uniqueMap = new HashMap<>(); + Map typeMap = new HashMap<>(); + + while (rs.next()) { + String indexName = rs.getString("Key_name"); + String columnName = rs.getString("Column_name"); + int sequence = rs.getInt("Seq_in_index"); + int nonUnique = rs.getInt("Non_unique"); + String indexType = rs.getString("Index_type"); + + indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>()) + .add(new IndexColumn(sequence, columnName)); + uniqueMap.put(indexName, nonUnique == 0); + typeMap.put(indexName, indexType); + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : indexMap.entrySet()) { + String name = entry.getKey(); + List columns = entry.getValue().stream() + .sorted(Comparator.comparingInt(IndexColumn::sequence)) + .map(IndexColumn::name) + .toList(); + result.add(new IndexInfo( + name, + columns, + uniqueMap.getOrDefault(name, false), + "PRIMARY".equals(name), + null, + typeMap.get(name), + null, + null + )); + } + return result; + } + }); + } + + @Override + public List listForeignKeys(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY CONSTRAINT_NAME + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("CONSTRAINT_NAME"), + rs.getString("COLUMN_NAME"), + rs.getString("REFERENCED_TABLE_NAME"), + rs.getString("REFERENCED_COLUMN_NAME") + )); + } + } + } + return result; + }); + } + + @Override + public List listTriggers(String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + try (var stmt = requireConnected().prepareStatement( + """ + SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING + FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? + ORDER BY TRIGGER_NAME + """ + )) { + stmt.setString(1, schema); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TriggerInfo( + rs.getString("TRIGGER_NAME"), + rs.getString("EVENT_MANIPULATION"), + rs.getString("ACTION_TIMING") + )); + } + } + } + return result; + }); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::getResultValue + ); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private static String buildUrl(ConnectParams params) { + return "jdbc:sundb://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase(); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static boolean includesSupportedObjects(MetadataListConstraints constraints) { + return constraints.includesTableLikeTypes() + || constraints.objectTypeAllowed("PROCEDURE") + || constraints.objectTypeAllowed("FUNCTION"); + } + + private static void appendInformationSchemaTableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND TABLE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static void appendRoutineTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.objectTypeAllowed("PROCEDURE")) { + types.add("PROCEDURE"); + } + if (constraints.objectTypeAllowed("FUNCTION")) { + types.add("FUNCTION"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND ROUTINE_TYPE IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static String normalizeTableType(String tableType) { + return "BASE TABLE".equals(tableType) ? "TABLE" : tableType; + } + + private static Integer integerOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Integer ? (Integer) value : null; + } + + private static Integer numberToIntOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + private Object getResultValue(ResultSet rs, int index, int sqlType) { + try { + Object value = switch (sqlType) { + case Types.BIGINT -> rs.getLong(index); + case Types.INTEGER, Types.SMALLINT, Types.TINYINT -> rs.getInt(index); + case Types.FLOAT, Types.REAL -> rs.getFloat(index); + case Types.DOUBLE -> rs.getDouble(index); + case Types.DECIMAL, Types.NUMERIC -> rs.getBigDecimal(index); + case Types.BOOLEAN, Types.BIT -> rs.getBoolean(index); + default -> rs.getString(index); + }; + return rs.wasNull() ? null : value; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private record IndexColumn(int sequence, String name) { + } + + public static void main(String[] args) { + new JsonRpcServer(new SundbAgent()).run(); + } +} diff --git a/agents/drivers/sundb/src/test/java/com/dbx/agent/sundb/SundbAgentMetadataTest.java b/agents/drivers/sundb/src/test/java/com/dbx/agent/sundb/SundbAgentMetadataTest.java new file mode 100644 index 0000000..2fd7c91 --- /dev/null +++ b/agents/drivers/sundb/src/test/java/com/dbx/agent/sundb/SundbAgentMetadataTest.java @@ -0,0 +1,53 @@ +package com.dbx.agent.sundb; + +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.TestSupport; +import com.dbx.agent.test.JdbcAgentFake; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class SundbAgentMetadataTest { + @Test + void quotesSchemaAndTableIdentifiersInIndexMetadataSql() { + SundbAgent agent = new SundbAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listIndexes("bad`schema", "bad`table"); + + Assertions.assertEquals( + Collections.singletonList("SHOW INDEX FROM `bad``table` FROM `bad``schema`"), + JdbcMetadataSqlFake.statements + ); + } + + @Test + void constrainedTableMetadataPushesFilterTypesAndPaging() { + SundbAgent agent = new SundbAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("app", new MetadataListConstraints("ord", 25, 50, List.of("TABLE", "VIEW"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM information_schema.TABLES"), sql); + Assertions.assertTrue(sql.contains("TABLE_TYPE IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("UPPER(TABLE_NAME) LIKE ? ESCAPE '\\\\'"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql); + } + + @Test + void constrainedObjectMetadataPushesRoutineTypesAndPaging() { + SundbAgent agent = new SundbAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listObjects("app", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM information_schema.ROUTINES"), sql); + Assertions.assertTrue(sql.contains("ROUTINE_TYPE IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("ORDER BY CASE OBJECT_TYPE"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 10"), sql); + } +} diff --git a/agents/drivers/sundb/src/test/java/com/dbx/agent/sundb/SundbAgentTest.java b/agents/drivers/sundb/src/test/java/com/dbx/agent/sundb/SundbAgentTest.java new file mode 100644 index 0000000..1a56380 --- /dev/null +++ b/agents/drivers/sundb/src/test/java/com/dbx/agent/sundb/SundbAgentTest.java @@ -0,0 +1,16 @@ +package com.dbx.agent.sundb; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; + +class SundbAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new SundbAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } +} diff --git a/agents/drivers/tdengine/build.gradle b/agents/drivers/tdengine/build.gradle new file mode 100644 index 0000000..3d53791 --- /dev/null +++ b/agents/drivers/tdengine/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.taosdata.jdbc:taos-jdbcdriver:3.6.3' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'TDengine', 'Main-Class': 'com.dbx.agent.tdengine.TDengineAgent') + } +} diff --git a/agents/drivers/tdengine/src/main/java/com/dbx/agent/tdengine/TDengineAgent.java b/agents/drivers/tdengine/src/main/java/com/dbx/agent/tdengine/TDengineAgent.java new file mode 100644 index 0000000..f9cb2e2 --- /dev/null +++ b/agents/drivers/tdengine/src/main/java/com/dbx/agent/tdengine/TDengineAgent.java @@ -0,0 +1,743 @@ +package com.dbx.agent.tdengine; + +import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class TDengineAgent extends BaseDatabaseAgent { + private static final DateTimeFormatter TDENGINE_TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + private static final Pattern NUMERIC_PRECISION_PATTERN = + Pattern.compile("(?i)^(decimal|numeric)\\((\\d+)(?:,\\s*\\d+)?\\)"); + private static final Pattern NUMERIC_SCALE_PATTERN = + Pattern.compile("(?i)^(decimal|numeric)\\(\\d+,\\s*(\\d+)\\)"); + private static final Pattern CHARACTER_LENGTH_PATTERN = + Pattern.compile("(?i)^(binary|nchar|varchar|varbinary)\\((\\d+)\\)"); + private static final Pattern COMPOSITE_KEY_PATTERN = + Pattern.compile("(?i)\\bCOMPOSITE\\s+KEY\\b"); + + private Connection connection; + + @Override + public Connection getConnection() { + return connection; + } + + @Override + public void connect(ConnectParams params) { + uncheckedVoid(() -> { + connection = TDengineConnectionFactory.open(params); + }); + } + + @Override + public boolean testConnection(ConnectParams params) { + return unchecked(() -> { + try (Connection conn = TDengineConnectionFactory.open(params)) { + return conn.isValid(5); + } + }); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW DATABASES")) { + while (rs.next()) { + String name = rs.getString(1); + if (!isSystemDatabase(name)) { + result.add(new DatabaseInfo(name)); + } + } + } + return result; + }); + } + + @Override + public List listSchemas() { + return Collections.emptyList(); + } + + @Override + public List listTables(String schema) { + return listTables(schema, MetadataListConstraints.NONE); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (!normalized.tableTypeAllowed("TABLE")) { + return Collections.emptyList(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException ignored) { + // TDengine 2.x does not expose information_schema.ins_stables/ins_tables. + return normalized.filterTables(listTablesLegacy(schema)); + } + } + + private List listTablesLegacy(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + result.addAll(queryTables("SHOW " + quoteQualifiedPrefix(schema) + "STABLES", "STABLE")); + result.addAll(queryTables("SHOW " + quoteQualifiedPrefix(schema) + "TABLES", "TABLE")); + + Map distinct = new LinkedHashMap<>(); + for (TableInfo table : result) { + distinct.putIfAbsent(table.getTable_type() + ":" + table.getName(), table); + } + List sorted = new ArrayList<>(distinct.values()); + sortTablesForHierarchy(sorted); + return sorted; + }); + } + + @Override + public List listObjects(String schema) { + List result = new ArrayList<>(); + for (TableInfo table : listTables(schema)) { + result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); + } + return result; + } + + @Override + public List getColumns(String schema, String table) { + return unchecked(() -> { + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("DESCRIBE " + qualifiedName(schema, table))) { + return readDescribeColumns(rs); + } + }); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + String source = getCreateSql(schema, name, objectType); + if (source.isBlank()) { + source = getTableDdl(schema, name); + } + return new ObjectSource(name, objectType, schema, source); + } + + @Override + public String getTableDdl(String schema, String table) { + String source = getCreateSql(schema, table, "STABLE"); + if (source.isBlank()) { + source = getCreateSql(schema, table, "TABLE"); + } + if (source.isBlank()) { + return super.getTableDdl(schema, table); + } + return source; + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + return JdbcExecutor.INSTANCE.execute( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options.getMaxRows(), + options.getFetchSize(), + options.getTimeoutSecs(), + this::tdengineResultValue + ); + } + + @Override + public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.executePage( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + this::tdengineResultValue + ); + } + + @Override + public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) { + return JdbcExecutor.INSTANCE.startTableRead( + requireConnected(), + sql, + schema, + this::setSchemaSQL, + options, + this::tdengineResultValue + ); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + quoteIdentifier(schema); + } + + @Override + public void disconnect() { + uncheckedVoid(() -> { + if (connection != null) { + connection.close(); + } + connection = null; + }); + } + + private List queryTables(String sql, String tableType) throws Exception { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(new TableInfo(rs.getString(1), tableType, null)); + } + } + return result; + } + + private List queryConstrainedTables(String database, MetadataListConstraints constraints) { + return unchecked(() -> { + TableMetadataQuery query = buildTableMetadataQuery(database, constraints); + List result = new ArrayList<>(); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(query.sql())) { + MetadataSqlSupport.bind(stmt, query.args()); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString(1), + rs.getString(2), + optionalString(rs, 3), + null, + optionalString(rs, 4) + )); + } + } + } + MetadataListConstraints postFilter = constraints.hasLimit() ? constraints.withoutPaging() : constraints; + return postFilter.filterTables(result); + }); + } + + static TableMetadataQuery buildTableMetadataQuery(String database, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder(""" + SELECT table_name, table_type, table_comment, parent_name + FROM ( + SELECT stable_name AS table_name, + 'STABLE' AS table_type, + table_comment, + CAST(NULL AS VARCHAR(192)) AS parent_name, + stable_name AS hierarchy_name, + 0 AS hierarchy_rank + FROM information_schema.ins_stables + WHERE db_name = ? + UNION ALL + SELECT table_name, + 'TABLE' AS table_type, + table_comment, + stable_name AS parent_name, + CASE WHEN stable_name IS NULL THEN table_name ELSE stable_name END AS hierarchy_name, + 1 AS hierarchy_rank + FROM information_schema.ins_tables + WHERE db_name = ? + ) metadata + WHERE 1 = 1 + """.stripIndent().trim()); + args.add(database); + args.add(database); + if (normalized.hasFilter()) { + String pattern = normalized.fuzzyLikePattern(); + sql.append(" AND (table_name LIKE ? OR table_comment LIKE ?)"); + args.add(pattern); + args.add(pattern); + } + sql.append(" ORDER BY hierarchy_name, hierarchy_rank, table_name"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, normalized); + return new TableMetadataQuery(sql.toString(), args); + } + + static final class TableMetadataQuery { + private final String sql; + private final List args; + + TableMetadataQuery(String sql, List args) { + this.sql = sql; + this.args = args; + } + + String sql() { + return sql; + } + + List args() { + return args; + } + } + + static void sortTablesForHierarchy(List tables) { + tables.sort((left, right) -> { + String leftGroup = hierarchyGroupName(left); + String rightGroup = hierarchyGroupName(right); + int groupCompared = leftGroup.compareTo(rightGroup); + if (groupCompared != 0) { + return groupCompared; + } + + boolean leftIsParent = left.getParent_name() == null; + boolean rightIsParent = right.getParent_name() == null; + if (leftIsParent != rightIsParent) { + return leftIsParent ? -1 : 1; + } + return left.getName().toLowerCase(Locale.ROOT).compareTo(right.getName().toLowerCase(Locale.ROOT)); + }); + } + + private static String hierarchyGroupName(TableInfo table) { + String parentName = table.getParent_name(); + String groupName = parentName == null || parentName.trim().isEmpty() ? table.getName() : parentName; + return groupName.toLowerCase(Locale.ROOT); + } + + static List readDescribeColumns(ResultSet rs) throws Exception { + List result = new ArrayList<>(); + int ordinal = 0; + while (rs.next()) { + ordinal += 1; + String name = rs.getString(1); + String dataType = coalesce(rs.getString(2)); + String note = optionalString(rs, 4); + boolean isTag = note != null && note.toUpperCase(Locale.ROOT).contains("TAG"); + boolean isPrimaryKey = !isTag && (ordinal == 1 || (note != null && COMPOSITE_KEY_PATTERN.matcher(note).find())); + result.add(new ColumnInfo( + name, + dataType, + !isPrimaryKey, + null, + isPrimaryKey, + note, + isTag ? "TAG" : null, + parseNumericPrecision(dataType), + parseNumericScale(dataType), + parseCharacterMaximumLength(dataType) + )); + } + return result; + } + + private String getCreateSql(String schema, String name, String objectType) { + String showType = switch (objectType.toUpperCase(Locale.ROOT)) { + case "STABLE", "SUPER TABLE", "SUPERTABLE" -> "STABLE"; + case "TABLE", "BASE TABLE", "CHILD TABLE" -> "TABLE"; + default -> null; + }; + if (showType == null) { + return ""; + } + + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW CREATE " + showType + " " + qualifiedName(schema, name))) { + if (!rs.next()) { + return ""; + } + int columnCount = rs.getMetaData().getColumnCount(); + for (int i = 2; i <= columnCount; i++) { + String value = rs.getString(i); + if (value != null && value.toUpperCase(Locale.ROOT).contains("CREATE")) { + return value; + } + } + String value = rs.getString(columnCount); + return value == null ? "" : value; + } catch (Exception e) { + return ""; + } + } + + private Object tdengineResultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = switch (sqlType) { + case Types.BIGINT -> rs.getLong(index); + case Types.INTEGER, Types.SMALLINT, Types.TINYINT -> rs.getInt(index); + case Types.FLOAT, Types.REAL -> rs.getFloat(index); + case Types.DOUBLE -> rs.getDouble(index); + case Types.DECIMAL, Types.NUMERIC -> rs.getBigDecimal(index); + case Types.BOOLEAN, Types.BIT -> rs.getBoolean(index); + default -> rs.getObject(index); + }; + return rs.wasNull() ? null : decodeTdengineValue(value); + }); + } + + public static Object decodeTdengineValue(Object value) { + if (value instanceof byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + if (value instanceof Timestamp timestamp) { + return TDENGINE_TIMESTAMP_FORMAT.format(timestamp.toLocalDateTime()); + } + return value; + } + + private static String quoteQualifiedPrefix(String schema) { + String trimmed = schema.trim(); + return trimmed.isEmpty() ? "" : quoteIdentifier(trimmed) + "."; + } + + private static String qualifiedName(String schema, String name) { + String table = quoteIdentifier(name); + String trimmed = schema.trim(); + return trimmed.isEmpty() ? table : quoteIdentifier(trimmed) + "." + table; + } + + private static String quoteIdentifier(String identifier) { + return "`" + identifier.replace("`", "``") + "`"; + } + + private static boolean isSystemDatabase(String name) { + if (name == null) { + return false; + } + String normalized = name.trim().toLowerCase(Locale.ROOT); + return "information_schema".equals(normalized) || "performance_schema".equals(normalized); + } + + private static Integer parseNumericPrecision(String dataType) { + return parseIntGroup(NUMERIC_PRECISION_PATTERN, dataType, 2); + } + + private static Integer parseNumericScale(String dataType) { + return parseIntGroup(NUMERIC_SCALE_PATTERN, dataType, 2); + } + + private static Integer parseCharacterMaximumLength(String dataType) { + return parseIntGroup(CHARACTER_LENGTH_PATTERN, dataType, 2); + } + + private static Integer parseIntGroup(Pattern pattern, String value, int group) { + Matcher matcher = pattern.matcher(value); + if (!matcher.find()) { + return null; + } + try { + return Integer.valueOf(matcher.group(group)); + } catch (NumberFormatException e) { + return null; + } + } + + private static String optionalString(ResultSet rs, int index) { + try { + return rs.getString(index); + } catch (Exception e) { + return null; + } + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + public static void main(String[] args) { + new JsonRpcServer(new TDengineAgent()).run(); + } +} + +final class TDengineJdbcUrl { + enum TransportPreference { + AUTO, + WEBSOCKET, + REST + } + + private static final String PARAM_TRANSPORT = "transport"; + private static final String PARAM_DBX_TRANSPORT = "dbx.transport"; + + private TDengineJdbcUrl() { + } + + static String from(ConnectParams params) { + TransportPreference preference = transportPreference(params.getUrl_params()); + if (preference == TransportPreference.REST) { + return from(params, TDengineTransport.REST); + } + String host = params.getHost().isBlank() ? "localhost" : params.getHost(); + int port = params.getPort() > 0 ? params.getPort() : 6041; + String database = params.getDatabase().trim(); + String path = database.isBlank() ? "/" : "/" + database; + String query = normalizeQueryString(params.getUrl_params()); + String suffix = query.isBlank() ? "" : "?" + query; + return "jdbc:TAOS-WS://" + host + ":" + port + path + suffix; + } + + static String from(ConnectParams params, TDengineTransport transport) { + String host = params.getHost().isBlank() ? "localhost" : params.getHost(); + int port = params.getPort() > 0 ? params.getPort() : 6041; + String database = params.getDatabase().trim(); + String path = database.isBlank() ? "/" : "/" + database; + String query = normalizeQueryString(params.getUrl_params()); + String suffix = query.isBlank() ? "" : "?" + query; + return transport.urlPrefix() + host + ":" + port + path + suffix; + } + + static String sanitizeConnectionString(String connectionString) { + if (connectionString == null || connectionString.isBlank()) { + return ""; + } + int queryIndex = connectionString.indexOf('?'); + if (queryIndex < 0) { + return connectionString; + } + String base = connectionString.substring(0, queryIndex); + String rawQuery = connectionString.substring(queryIndex + 1); + int fragmentIndex = rawQuery.indexOf('#'); + String fragment = ""; + if (fragmentIndex >= 0) { + fragment = rawQuery.substring(fragmentIndex); + rawQuery = rawQuery.substring(0, fragmentIndex); + } + String query = normalizeQueryString(rawQuery); + if (query.isBlank()) { + return base + fragment; + } + return base + "?" + query + fragment; + } + + static TransportPreference transportPreference(String rawQuery) { + String query = rawQuery == null ? "" : rawQuery.trim(); + if (query.startsWith("?")) { + query = query.substring(1); + } + if (query.isBlank()) { + return TransportPreference.AUTO; + } + String[] parts = query.split("&"); + for (String part : parts) { + if (part == null || part.isBlank()) { + continue; + } + int index = part.indexOf('='); + String key = index < 0 ? part.trim() : part.substring(0, index).trim(); + if (!PARAM_TRANSPORT.equalsIgnoreCase(key) && !PARAM_DBX_TRANSPORT.equalsIgnoreCase(key)) { + continue; + } + String value = index < 0 ? "" : part.substring(index + 1).trim(); + if ("rest".equalsIgnoreCase(value) || "rs".equalsIgnoreCase(value)) { + return TransportPreference.REST; + } + if ("ws".equalsIgnoreCase(value) || "websocket".equalsIgnoreCase(value)) { + return TransportPreference.WEBSOCKET; + } + } + return TransportPreference.AUTO; + } + + private static String normalizeQueryString(String rawQuery) { + String query = rawQuery == null ? "" : rawQuery.trim(); + if (query.startsWith("?")) { + query = query.substring(1); + } + if (query.isBlank()) { + return ""; + } + String[] parts = query.split("&"); + List kept = new ArrayList<>(); + for (String part : parts) { + if (part == null || part.isBlank()) { + continue; + } + int index = part.indexOf('='); + String key = index < 0 ? part.trim() : part.substring(0, index).trim(); + if (PARAM_TRANSPORT.equalsIgnoreCase(key) || PARAM_DBX_TRANSPORT.equalsIgnoreCase(key)) { + continue; + } + kept.add(part); + } + return String.join("&", kept); + } +} + +enum TDengineTransport { + WEBSOCKET("jdbc:TAOS-WS://", "com.taosdata.jdbc.ws.WebSocketDriver"), + REST("jdbc:TAOS-RS://", "com.taosdata.jdbc.rs.RestfulDriver"); + + private final String urlPrefix; + private final String driverClass; + + TDengineTransport(String urlPrefix, String driverClass) { + this.urlPrefix = urlPrefix; + this.driverClass = driverClass; + } + + String urlPrefix() { + return urlPrefix; + } + + String driverClass() { + return driverClass; + } +} + +final class TDengineConnectionFactory { + private static final int AUTO_TRANSPORT_ATTEMPT_TIMEOUT_SECONDS = 8; + + private TDengineConnectionFactory() { + } + + static Connection open(ConnectParams params) throws Exception { + String explicitConnectionString = params.getConnection_string() == null ? "" : params.getConnection_string().trim(); + if (!explicitConnectionString.isBlank()) { + String sanitizedConnectionString = TDengineJdbcUrl.sanitizeConnectionString(explicitConnectionString); + Class.forName(driverClassFromConnectionString(sanitizedConnectionString)); + return DriverManager.getConnection(sanitizedConnectionString, params.getUsername(), params.getPassword()); + } + + TDengineJdbcUrl.TransportPreference preference = TDengineJdbcUrl.transportPreference(params.getUrl_params()); + TDengineTransport[] candidates = transportsFor(preference); + Exception firstError = null; + for (TDengineTransport transport : candidates) { + try { + Connection connection = preference == TDengineJdbcUrl.TransportPreference.AUTO + ? openWithAttemptTimeout(params, transport) + : openDirect(params, transport); + return connection; + } catch (Exception e) { + if (firstError == null) { + firstError = e; + } else { + firstError.addSuppressed(e); + } + } + } + if (firstError != null) { + throw new RuntimeException("Failed to connect TDengine using transports: " + candidateNames(candidates), firstError); + } + throw new IllegalStateException("No TDengine transport available."); + } + + private static Connection openDirect(ConnectParams params, TDengineTransport transport) throws Exception { + Class.forName(transport.driverClass()); + String url = TDengineJdbcUrl.from(params, transport); + return DriverManager.getConnection(url, params.getUsername(), params.getPassword()); + } + + private static Connection openWithAttemptTimeout(ConnectParams params, TDengineTransport transport) throws Exception { + String threadName = "tdengine-connect-" + transport.name().toLowerCase(Locale.ROOT); + ThreadFactory factory = runnable -> { + Thread thread = new Thread(runnable, threadName); + thread.setDaemon(true); + return thread; + }; + ExecutorService executor = Executors.newSingleThreadExecutor(factory); + try { + Future future = executor.submit(new Callable() { + @Override + public Connection call() throws Exception { + return openDirect(params, transport); + } + }); + return future.get(AUTO_TRANSPORT_ATTEMPT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new RuntimeException( + "TDengine " + transport.name().toLowerCase(Locale.ROOT) + + " transport connect timed out after " + AUTO_TRANSPORT_ATTEMPT_TIMEOUT_SECONDS + "s", + e + ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while connecting TDengine via " + transport.name().toLowerCase(Locale.ROOT), e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception exception) { + throw exception; + } + throw new RuntimeException(cause); + } finally { + executor.shutdownNow(); + } + } + + private static TDengineTransport[] transportsFor(TDengineJdbcUrl.TransportPreference preference) { + return switch (preference) { + case REST -> new TDengineTransport[] {TDengineTransport.REST}; + case WEBSOCKET -> new TDengineTransport[] {TDengineTransport.WEBSOCKET}; + case AUTO -> new TDengineTransport[] {TDengineTransport.WEBSOCKET, TDengineTransport.REST}; + }; + } + + private static String candidateNames(TDengineTransport[] candidates) { + List names = new ArrayList<>(); + for (TDengineTransport candidate : candidates) { + names.add(candidate.name().toLowerCase(Locale.ROOT)); + } + return String.join(" -> ", names); + } + + private static String driverClassFromConnectionString(String connectionString) { + if (connectionString.regionMatches(true, 0, TDengineTransport.REST.urlPrefix(), 0, TDengineTransport.REST.urlPrefix().length())) { + return TDengineTransport.REST.driverClass(); + } + if (connectionString.regionMatches(true, 0, TDengineTransport.WEBSOCKET.urlPrefix(), 0, TDengineTransport.WEBSOCKET.urlPrefix().length())) { + return TDengineTransport.WEBSOCKET.driverClass(); + } + return TDengineTransport.WEBSOCKET.driverClass(); + } +} diff --git a/agents/drivers/tdengine/src/test/java/com/dbx/agent/tdengine/TDengineAgentPagingTest.java b/agents/drivers/tdengine/src/test/java/com/dbx/agent/tdengine/TDengineAgentPagingTest.java new file mode 100644 index 0000000..91ef0b9 --- /dev/null +++ b/agents/drivers/tdengine/src/test/java/com/dbx/agent/tdengine/TDengineAgentPagingTest.java @@ -0,0 +1,178 @@ +package com.dbx.agent.tdengine; + +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.sql.Types; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TDengineAgentPagingTest { + @Test + void executeQueryPageDecodesBinaryVarcharValuesAsStrings() { + TDengineAgent agent = new TDengineAgent(); + TestSupport.setPrivateConnection(agent, binaryVarcharConnection()); + JsonRpcServer server = new JsonRpcServer(agent); + + String response = handleRequest(server, """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "execute_query_page", + "params": { + "sql": "SELECT v FROM sample", + "schema": "power", + "pageSize": 100 + } + } + """); + + assertFalse(response.contains("\"error\""), response); + assertTrue(response.contains("\"hello\""), response); + assertFalse(response.contains("[104,101,108,108,111]"), response); + } + + private static Connection binaryVarcharConnection() { + Statement statement = binaryVarcharStatement(); + return proxy(Connection.class, (method, args) -> { + String name = method.getName(); + if ("createStatement".equals(name)) { + return statement; + } + if ("getAutoCommit".equals(name)) { + return true; + } + if ("setAutoCommit".equals(name) || "commit".equals(name) || "rollback".equals(name) || "close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Statement binaryVarcharStatement() { + ResultSet resultSet = binaryVarcharResultSet(); + return proxy(Statement.class, (method, args) -> { + String name = method.getName(); + if ("execute".equals(name)) { + return true; + } + if ("getResultSet".equals(name)) { + return resultSet; + } + if ("getUpdateCount".equals(name)) { + return 0; + } + if ("setMaxRows".equals(name) || "setFetchSize".equals(name) || "close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet binaryVarcharResultSet() { + final int[] index = {-1}; + ResultSetMetaData metadata = binaryVarcharMetadata(); + byte[] value = "hello".getBytes(StandardCharsets.UTF_8); + return proxy(ResultSet.class, (method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] == 0; + } + if ("getMetaData".equals(name)) { + return metadata; + } + if ("getObject".equals(name)) { + return value; + } + if ("wasNull".equals(name)) { + return false; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSetMetaData binaryVarcharMetadata() { + return proxy(ResultSetMetaData.class, (method, args) -> { + String name = method.getName(); + if ("getColumnCount".equals(name)) { + return 1; + } + if ("getColumnLabel".equals(name)) { + return "v"; + } + if ("getColumnType".equals(name)) { + return Types.VARCHAR; + } + return defaultValue(method.getReturnType()); + }); + } + + private static String handleRequest(JsonRpcServer server, String request) { + try { + Method method = JsonRpcServer.class.getDeclaredMethod("handleRequest", String.class); + method.setAccessible(true); + return (String) method.invoke(server, request); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/drivers/tdengine/src/test/java/com/dbx/agent/tdengine/TDengineAgentTest.java b/agents/drivers/tdengine/src/test/java/com/dbx/agent/tdengine/TDengineAgentTest.java new file mode 100644 index 0000000..b606577 --- /dev/null +++ b/agents/drivers/tdengine/src/test/java/com/dbx/agent/tdengine/TDengineAgentTest.java @@ -0,0 +1,347 @@ +package com.dbx.agent.tdengine; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.TableInfo; +import com.dbx.agent.test.JdbcAgentFake; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.test.TestSupport; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TDengineAgentExecutionTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new TDengineAgent(); + } + + @Override + protected String resultSetSql() { + return "SHOW DATABASES"; + } +} + +class TDengineAgentMetadataTest { + @Test + void buildsWebsocketJdbcUrlWithDefaultPortAndDatabase() { + String url = TDengineJdbcUrl.from( + new ConnectParams("127.0.0.1", 0, "meters", "root", "taosdata", "", "", false) + ); + + Assertions.assertEquals("jdbc:TAOS-WS://127.0.0.1:6041/meters", url); + } + + @Test + void preservesCustomWebsocketPortAndUrlParams() { + String url = TDengineJdbcUrl.from( + new ConnectParams("td.local", 6042, "", "", "", "timezone=UTC&charset=UTF-8", "", false) + ); + + Assertions.assertEquals("jdbc:TAOS-WS://td.local:6042/?timezone=UTC&charset=UTF-8", url); + } + + @Test + void supportsRestTransportViaCompatibilityUrlParam() { + String url = TDengineJdbcUrl.from( + new ConnectParams("127.0.0.1", 0, "testdb", "", "", "dbx.transport=rest&charset=UTF-8", "", false) + ); + + Assertions.assertEquals("jdbc:TAOS-RS://127.0.0.1:6041/testdb?charset=UTF-8", url); + } + + @Test + void stripsTransportControlParamFromJdbcQuery() { + String url = TDengineJdbcUrl.from( + new ConnectParams("127.0.0.1", 6041, "", "", "", "transport=ws&timezone=UTC", "", false) + ); + + Assertions.assertEquals("jdbc:TAOS-WS://127.0.0.1:6041/?timezone=UTC", url); + } + + @Test + void usesTdengineMetadataStatements() { + TDengineAgent agent = new TDengineAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listDatabases(); + agent.listTables("power"); + agent.getColumns("power", "meters"); + + Assertions.assertEquals("SHOW DATABASES", JdbcMetadataSqlFake.statements.get(0)); + Assertions.assertTrue(JdbcMetadataSqlFake.statements.get(1).contains("FROM information_schema.ins_stables")); + Assertions.assertTrue(JdbcMetadataSqlFake.statements.get(1).contains("FROM information_schema.ins_tables")); + Assertions.assertEquals("param:1=power", JdbcMetadataSqlFake.statements.get(2)); + Assertions.assertEquals("param:2=power", JdbcMetadataSqlFake.statements.get(3)); + Assertions.assertEquals("DESCRIBE `power`.`meters`", JdbcMetadataSqlFake.statements.get(4)); + } + + @Test + void constrainedMetadataUsesRequestedDatabaseAndPushesFilterAndPaging() { + TDengineAgent agent = new TDengineAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables( + "dbx_alpha", + new MetadataListConstraints("ord", 25, 50, List.of("TABLE")) + ); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertFalse(sql.contains("DATABASE()"), sql); + Assertions.assertTrue(sql.contains("FROM information_schema.ins_stables"), sql); + Assertions.assertTrue(sql.contains("FROM information_schema.ins_tables"), sql); + Assertions.assertTrue(sql.contains("WHERE db_name = ?"), sql); + Assertions.assertTrue(sql.contains("table_name LIKE ? OR table_comment LIKE ?"), sql); + Assertions.assertTrue(sql.contains("ORDER BY hierarchy_name, hierarchy_rank, table_name"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql); + Assertions.assertEquals( + List.of( + "param:1=dbx_alpha", + "param:2=dbx_alpha", + "param:3=%o%r%d%", + "param:4=%o%r%d%" + ), + JdbcMetadataSqlFake.statements.subList(1, 5) + ); + } + + @Test + void constrainedMetadataPagesDoNotRepeatOrSkipTables() { + List metadata = List.of( + new TableInfo("meters", "STABLE", null), + new TableInfo("device_a", "TABLE", null, null, "meters"), + new TableInfo("device_b", "TABLE", null, null, "meters"), + new TableInfo("standalone", "TABLE", null), + new TableInfo("weather", "STABLE", null) + ); + List statements = new ArrayList<>(); + TDengineAgent agent = new TDengineAgent(); + TestSupport.setPrivateConnection(agent, pagedMetadataConnection(metadata, statements)); + + List combined = new ArrayList<>(); + combined.addAll(agent.listTables("dbx_alpha", new MetadataListConstraints(null, 2, null, List.of("TABLE")))); + combined.addAll(agent.listTables("dbx_alpha", new MetadataListConstraints(null, 2, 2, List.of("TABLE")))); + combined.addAll(agent.listTables("dbx_alpha", new MetadataListConstraints(null, 2, 4, List.of("TABLE")))); + + Assertions.assertEquals(metadata.stream().map(TableInfo::getName).toList(), combined.stream().map(TableInfo::getName).toList()); + Set distinctNames = new HashSet<>(combined.stream().map(TableInfo::getName).toList()); + Assertions.assertEquals(metadata.size(), distinctNames.size()); + Assertions.assertTrue(statements.get(0).endsWith("LIMIT 2"), statements.get(0)); + Assertions.assertTrue(statements.get(1).endsWith("LIMIT 2 OFFSET 2"), statements.get(1)); + Assertions.assertTrue(statements.get(2).endsWith("LIMIT 2 OFFSET 4"), statements.get(2)); + } + + @Test + void sortsSupertablesBeforeTheirChildTables() { + List tables = new ArrayList<>(Arrays.asList( + new TableInfo("device_b", "TABLE", null, null, "meters"), + new TableInfo("standalone", "TABLE", null), + new TableInfo("meters", "STABLE", null), + new TableInfo("device_a", "TABLE", null, null, "meters") + )); + + TDengineAgent.sortTablesForHierarchy(tables); + + Assertions.assertEquals( + Arrays.asList("meters", "device_a", "device_b", "standalone"), + tables.stream().map(TableInfo::getName).toList() + ); + Assertions.assertEquals("meters", tables.get(1).getParent_name()); + } + + @Test + void marksTimestampAndCompositeKeyDescribeColumnsAsPrimaryKeys() throws Exception { + ResultSet resultSet = describeResultSet(new String[][] { + {"ts", "TIMESTAMP", "8", ""}, + {"seq", "INT", "4", "COMPOSITE KEY"}, + {"voltage", "FLOAT", "4", ""}, + {"site", "VARCHAR(32)", "32", "TAG"} + }); + + List columns = TDengineAgent.readDescribeColumns(resultSet); + + Assertions.assertTrue(columns.get(0).getIs_primary_key()); + Assertions.assertFalse(columns.get(0).getIs_nullable()); + Assertions.assertTrue(columns.get(1).getIs_primary_key()); + Assertions.assertFalse(columns.get(1).getIs_nullable()); + Assertions.assertFalse(columns.get(2).getIs_primary_key()); + Assertions.assertFalse(columns.get(3).getIs_primary_key()); + Assertions.assertEquals("TAG", columns.get(3).getExtra()); + } + + @Test + void doesNotExposeDatabasesAsSchemas() { + TDengineAgent agent = new TDengineAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + Assertions.assertTrue(agent.listSchemas().isEmpty()); + Assertions.assertTrue(JdbcMetadataSqlFake.statements.isEmpty()); + } + + private static Connection pagedMetadataConnection(List metadata, List statements) { + return proxy(Connection.class, (proxy, method, args) -> { + String name = method.getName(); + if ("prepareStatement".equals(name)) { + String sql = (String) args[0]; + statements.add(sql); + return pagedMetadataStatement(sql, metadata); + } + if ("isClosed".equals(name)) return false; + if ("close".equals(name)) return null; + return defaultValue(method.getReturnType()); + }); + } + + private static PreparedStatement pagedMetadataStatement(String sql, List metadata) { + return proxy(PreparedStatement.class, (proxy, method, args) -> { + String name = method.getName(); + if ("executeQuery".equals(name)) { + int limit = sqlClauseValue(sql, "LIMIT", metadata.size()); + int offset = sqlClauseValue(sql, "OFFSET", 0); + int from = Math.min(offset, metadata.size()); + int to = Math.min(from + limit, metadata.size()); + return tableInfoResultSet(metadata.subList(from, to)); + } + if ("setString".equals(name) || "setInt".equals(name) || "setObject".equals(name) || "close".equals(name)) return null; + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet tableInfoResultSet(List tables) { + int[] index = {-1}; + return proxy(ResultSet.class, (proxy, method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] < tables.size(); + } + if ("getString".equals(name)) { + TableInfo table = tables.get(index[0]); + int column = (Integer) args[0]; + if (column == 1) return table.getName(); + if (column == 2) return table.getTable_type(); + if (column == 3) return table.getComment(); + if (column == 4) return table.getParent_name(); + } + if ("close".equals(name)) return null; + return defaultValue(method.getReturnType()); + }); + } + + private static int sqlClauseValue(String sql, String clause, int fallback) { + Matcher matcher = Pattern.compile("\\b" + clause + "\\s+(\\d+)").matcher(sql); + return matcher.find() ? Integer.parseInt(matcher.group(1)) : fallback; + } + + private static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) return false; + if (Integer.TYPE.equals(type)) return 0; + if (Long.TYPE.equals(type)) return 0L; + if (Double.TYPE.equals(type)) return 0.0d; + if (Float.TYPE.equals(type)) return 0.0f; + if (Short.TYPE.equals(type)) return (short) 0; + if (Byte.TYPE.equals(type)) return (byte) 0; + if (Character.TYPE.equals(type)) return '\0'; + return null; + } + + @Test + void setsDatabaseBeforeExecutionWhenSchemaIsProvided() { + TDengineAgent agent = new TDengineAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + agent.executeQuery("SELECT 1", "power", new ExecuteQueryOptions()); + + // JdbcSchemaSwitcher executes the USE statement (recorded as "execute") + // before the query runs, so the database is switched prior to execution. + Assertions.assertEquals( + Arrays.asList("execute", "setMaxRows:10001", "execute"), + JdbcAgentFake.calls + ); + } + + @Test + void decodesTdengineByteArrayTextValues() { + Assertions.assertEquals( + "d1001", + TDengineAgent.decodeTdengineValue("d1001".getBytes(StandardCharsets.UTF_8)) + ); + } + + @Test + void formatsTdengineTimestampsAsSqlLiterals() { + Assertions.assertEquals( + "2026-05-16 09:35:58.123", + TDengineAgent.decodeTdengineValue(Timestamp.valueOf("2026-05-16 09:35:58.123")) + ); + } + + @Test + void unknownTransportValueDefaultsToWebsocketAndKeepsOtherParams() { + String url = TDengineJdbcUrl.from( + new ConnectParams("127.0.0.1", 6041, "", "", "", "transport=foo&timezone=UTC", "", false) + ); + + Assertions.assertEquals("jdbc:TAOS-WS://127.0.0.1:6041/?timezone=UTC", url); + } + + @Test + void sanitizeConnectionStringStripsTransportControlParams() { + String sanitized = TDengineJdbcUrl.sanitizeConnectionString( + "jdbc:TAOS-WS://127.0.0.1:6041/db?dbx.transport=rest&charset=UTF-8&transport=ws" + ); + + Assertions.assertEquals("jdbc:TAOS-WS://127.0.0.1:6041/db?charset=UTF-8", sanitized); + } + + @Test + void sanitizeConnectionStringKeepsFragmentAndNonControlParams() { + String sanitized = TDengineJdbcUrl.sanitizeConnectionString( + "jdbc:TAOS-RS://127.0.0.1:6041/db?timezone=UTC&dbx.transport=rest#anchor" + ); + + Assertions.assertEquals("jdbc:TAOS-RS://127.0.0.1:6041/db?timezone=UTC#anchor", sanitized); + } + + private static ResultSet describeResultSet(String[][] rows) { + int[] rowIndex = {-1}; + return (ResultSet) Proxy.newProxyInstance( + ResultSet.class.getClassLoader(), + new Class[] {ResultSet.class}, + (proxy, method, args) -> { + if ("next".equals(method.getName())) { + rowIndex[0] += 1; + return rowIndex[0] < rows.length; + } + if ("getString".equals(method.getName())) { + return rows[rowIndex[0]][((Integer) args[0]) - 1]; + } + if ("close".equals(method.getName())) { + return null; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } +} diff --git a/agents/drivers/teradata/build.gradle b/agents/drivers/teradata/build.gradle new file mode 100644 index 0000000..73c6bad --- /dev/null +++ b/agents/drivers/teradata/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.teradata.jdbc:terajdbc:20.00.00.56' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Teradata', 'Main-Class': 'com.dbx.agent.teradata.TeradataAgent') + } +} diff --git a/agents/drivers/teradata/src/main/java/com/dbx/agent/teradata/TeradataAgent.java b/agents/drivers/teradata/src/main/java/com/dbx/agent/teradata/TeradataAgent.java new file mode 100644 index 0000000..f24aafc --- /dev/null +++ b/agents/drivers/teradata/src/main/java/com/dbx/agent/teradata/TeradataAgent.java @@ -0,0 +1,21 @@ +package com.dbx.agent.teradata; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class TeradataAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile TERADATA_PROFILE = new JdbcAgentProfile( + "com.teradata.jdbc.TeraDriver", + "jdbc:teradata://{host}/DBS_PORT={port},DATABASE={database}", + 1025 + ); + + public TeradataAgent() { + super(TERADATA_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new TeradataAgent()).run(); + } +} diff --git a/agents/drivers/trino/build.gradle b/agents/drivers/trino/build.gradle new file mode 100644 index 0000000..28b6cd8 --- /dev/null +++ b/agents/drivers/trino/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'io.trino:trino-jdbc:471' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Trino (Presto)', 'Main-Class': 'com.dbx.agent.trino.TrinoAgent') + } +} diff --git a/agents/drivers/trino/src/main/java/com/dbx/agent/trino/TrinoAgent.java b/agents/drivers/trino/src/main/java/com/dbx/agent/trino/TrinoAgent.java new file mode 100644 index 0000000..e0e348a --- /dev/null +++ b/agents/drivers/trino/src/main/java/com/dbx/agent/trino/TrinoAgent.java @@ -0,0 +1,345 @@ +package com.dbx.agent.trino; + +import com.dbx.agent.AbstractJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.DatabaseInfo; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; +import com.dbx.agent.JdbcIdentifiers; +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.MetadataSqlSupport; +import com.dbx.agent.TableInfo; +import com.dbx.agent.TriggerInfo; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class TrinoAgent extends AbstractJdbcAgent { + private static final Pattern NUMERIC_PRECISION_PATTERN = Pattern.compile("(?i)^(decimal|numeric)\\((\\d+)(?:,\\s*\\d+)?\\)"); + private static final Pattern NUMERIC_SCALE_PATTERN = Pattern.compile("(?i)^(decimal|numeric)\\(\\d+,\\s*(\\d+)\\)"); + private static final Pattern CHARACTER_LENGTH_PATTERN = Pattern.compile("(?i)^(char|varchar)\\((\\d+)\\)"); + + @Override + protected String driverClass() { + return "io.trino.jdbc.TrinoDriver"; + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + return buildUrl(params); + } + + @Override + public List listDatabases() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW CATALOGS")) { + while (rs.next()) { + result.add(new DatabaseInfo(rs.getString(1))); + } + } + return result; + }); + } + + @Override + public List listSchemas() { + return unchecked(() -> { + List result = new ArrayList<>(); + try (java.sql.Statement stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SHOW SCHEMAS")) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } + return result; + }); + } + + @Override + public List listTables(String schema) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "SELECT table_name, table_type " + + "FROM information_schema.tables " + + "WHERE table_schema = ? " + + "ORDER BY table_name"; + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("table_name"), + normalizeTableType(rs.getString("table_type")), + null + )); + } + } + } + return result; + }); + } + + @Override + public List listTables(String schema, MetadataListConstraints constraints) { + MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints); + if (isUnconstrained(normalized)) { + return listTables(schema); + } + if (!normalized.includesTableLikeTypes()) { + return List.of(); + } + try { + return queryConstrainedTables(schema, normalized); + } catch (RuntimeException e) { + return normalized.filterTables(listTables(schema)); + } + } + + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { + return unchecked(() -> { + List result = new ArrayList<>(); + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = ?"); + args.add(schema); + appendTrinoTableTypePredicate(sql, args, constraints); + MetadataSqlSupport.appendNameFilter(sql, args, "table_name", constraints); + sql.append(" ORDER BY table_name"); + MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); + try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) { + MetadataSqlSupport.bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableInfo( + rs.getString("table_name"), + normalizeTableType(rs.getString("table_type")), + null + )); + } + } + } + return constraints.withoutPaging().filterTables(result); + }); + } + + @Override + public List getColumns(String schema, String table) { + Connection conn = requireConnected(); + List metadataColumns; + try { + metadataColumns = getColumnsFromMetadata(conn, schema, table); + } catch (RuntimeException e) { + metadataColumns = Collections.emptyList(); + } + return metadataColumns.isEmpty() ? getColumnsFromDescribe(conn, schema, table) : metadataColumns; + } + + @Override + public List listIndexes(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listForeignKeys(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public List listTriggers(String schema, String table) { + return Collections.emptyList(); + } + + @Override + public String setSchemaSQL(String schema) { + return "USE " + JdbcIdentifiers.INSTANCE.doubleQuote(schema); + } + + private List getColumnsFromMetadata(Connection conn, String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String catalog = blankToNull(conn.getCatalog()); + try (ResultSet rs = conn.getMetaData().getColumns(catalog, schema, table, null)) { + while (rs.next()) { + int sqlType = rs.getInt("DATA_TYPE"); + Integer size = intOrNull(rs, "COLUMN_SIZE"); + Integer scale = intOrNull(rs, "DECIMAL_DIGITS"); + result.add(new ColumnInfo( + rs.getString("COLUMN_NAME"), + coalesce(rs.getString("TYPE_NAME")), + rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + rs.getString("COLUMN_DEF"), + false, + null, + rs.getString("REMARKS"), + isNumericType(sqlType) ? size : null, + isNumericType(sqlType) ? scale : null, + isCharacterType(sqlType) ? size : null + )); + } + } + return result; + }); + } + + private List getColumnsFromDescribe(Connection conn, String schema, String table) { + return unchecked(() -> { + List result = new ArrayList<>(); + String sql = "DESCRIBE " + quoteIdentifier(schema) + "." + quoteIdentifier(table); + try (java.sql.Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String dataType = rs.getString(2); + String extra = rs.getString(3); + result.add(new ColumnInfo( + rs.getString(1), + dataType, + !coalesce(extra).toLowerCase(java.util.Locale.ROOT).contains("not null"), + null, + false, + extra, + rs.getString(4), + parseNumericPrecision(dataType), + parseNumericScale(dataType), + parseCharacterMaximumLength(dataType) + )); + } + } + return result; + }); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + return unchecked(() -> { + Object value = rs.getObject(index); + return rs.wasNull() ? null : value == null ? null : value.toString(); + }); + } + + private static String buildUrl(ConnectParams params) { + if (params.getConnection_string() != null && !params.getConnection_string().isBlank()) { + return params.getConnection_string(); + } + return "jdbc:trino://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase(); + } + + private static boolean isUnconstrained(MetadataListConstraints constraints) { + return !constraints.hasFilter() && !constraints.hasLimit() && !constraints.hasOffset() && !constraints.hasObjectTypes(); + } + + private static void appendTrinoTableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + if (!constraints.hasObjectTypes()) { + return; + } + List types = new ArrayList<>(); + if (constraints.tableTypeAllowed("TABLE")) { + types.add("BASE TABLE"); + } + if (constraints.tableTypeAllowed("VIEW")) { + types.add("VIEW"); + } + if (constraints.tableTypeAllowed("MATERIALIZED_VIEW")) { + types.add("MATERIALIZED VIEW"); + } + if (types.isEmpty()) { + sql.append(" AND 1 = 0"); + return; + } + sql.append(" AND table_type IN (").append(MetadataSqlSupport.placeholders(types.size())).append(")"); + args.addAll(types); + } + + private static String quoteIdentifier(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static boolean isNumericType(int sqlType) { + switch (sqlType) { + case Types.BIGINT: + case Types.DECIMAL: + case Types.DOUBLE: + case Types.FLOAT: + case Types.INTEGER: + case Types.NUMERIC: + case Types.REAL: + case Types.SMALLINT: + case Types.TINYINT: + return true; + default: + return false; + } + } + + private static boolean isCharacterType(int sqlType) { + switch (sqlType) { + case Types.CHAR: + case Types.LONGNVARCHAR: + case Types.LONGVARCHAR: + case Types.NCHAR: + case Types.NVARCHAR: + case Types.VARCHAR: + return true; + default: + return false; + } + } + + private static Integer parseNumericPrecision(String dataType) { + return firstIntGroup(NUMERIC_PRECISION_PATTERN, dataType, 2); + } + + private static Integer parseNumericScale(String dataType) { + return firstIntGroup(NUMERIC_SCALE_PATTERN, dataType, 1); + } + + private static Integer parseCharacterMaximumLength(String dataType) { + return firstIntGroup(CHARACTER_LENGTH_PATTERN, dataType, 2); + } + + private static Integer firstIntGroup(Pattern pattern, String value, int group) { + if (value == null) { + return null; + } + Matcher matcher = pattern.matcher(value); + if (!matcher.find()) { + return null; + } + return Integer.valueOf(matcher.group(group)); + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static String normalizeTableType(String type) { + if ("BASE TABLE".equals(type)) { + return "TABLE"; + } + if ("MATERIALIZED VIEW".equals(type)) { + return "MATERIALIZED_VIEW"; + } + return type; + } + + private static String blankToNull(String value) { + return value == null || value.trim().isEmpty() ? null : value; + } + + private static String coalesce(String value) { + return value == null ? "" : value; + } + + public static void main(String[] args) { + new JsonRpcServer(new TrinoAgent()).run(); + } +} diff --git a/agents/drivers/trino/src/test/java/com/dbx/agent/trino/TrinoAgentTest.java b/agents/drivers/trino/src/test/java/com/dbx/agent/trino/TrinoAgentTest.java new file mode 100644 index 0000000..9b8943d --- /dev/null +++ b/agents/drivers/trino/src/test/java/com/dbx/agent/trino/TrinoAgentTest.java @@ -0,0 +1,37 @@ +package com.dbx.agent.trino; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import com.dbx.agent.test.JdbcMetadataSqlFake; +import com.dbx.agent.test.TestSupport; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class TrinoAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new TrinoAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL system.runtime.nodes()"; + } + + @Test + void constrainedTableMetadataPushesFilterTypesAndPaging() { + TrinoAgent agent = new TrinoAgent(); + TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection()); + + agent.listTables("public", new MetadataListConstraints("ord", 25, 50, List.of("TABLE", "VIEW"))); + + String sql = JdbcMetadataSqlFake.statements.get(0); + Assertions.assertTrue(sql.contains("FROM information_schema.tables"), sql); + Assertions.assertTrue(sql.contains("table_type IN (?, ?)"), sql); + Assertions.assertTrue(sql.contains("UPPER(table_name) LIKE ? ESCAPE '\\\\'"), sql); + Assertions.assertTrue(sql.endsWith("LIMIT 25 OFFSET 50"), sql); + } +} diff --git a/agents/drivers/vastbase/build.gradle b/agents/drivers/vastbase/build.gradle new file mode 100644 index 0000000..11ece4a --- /dev/null +++ b/agents/drivers/vastbase/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'cn.com.vastdata:vastbase-jdbc:2.11v' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Vastbase', 'Main-Class': 'com.dbx.agent.vastbase.VastbaseAgent') + } +} diff --git a/agents/drivers/vastbase/libs/.gitkeep b/agents/drivers/vastbase/libs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agents/drivers/vastbase/src/main/java/com/dbx/agent/vastbase/VastbaseAgent.java b/agents/drivers/vastbase/src/main/java/com/dbx/agent/vastbase/VastbaseAgent.java new file mode 100644 index 0000000..920db9b --- /dev/null +++ b/agents/drivers/vastbase/src/main/java/com/dbx/agent/vastbase/VastbaseAgent.java @@ -0,0 +1,61 @@ +package com.dbx.agent.vastbase; + +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.ObjectSource; +import com.dbx.agent.PostgresLikeAgent; +import com.dbx.agent.PostgresLikeAgentProfile; + +public final class VastbaseAgent extends PostgresLikeAgent { + public static final PostgresLikeAgentProfile VASTBASE_PROFILE = new PostgresLikeAgentProfile( + "cn.com.vastbase.Driver", + "jdbc:vastbase://{host}:{port}/{database}" + ); + + public VastbaseAgent() { + super(VASTBASE_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new VastbaseAgent()).run(); + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + ObjectSource result = super.getObjectSource(schema, name, objectType); + String source = unwrapRecordText(result.getSource()); + return new ObjectSource(result.getName(), result.getObject_type(), result.getSchema(), source); + } + + /** + * Vastbase 的 pg_get_functiondef 在 SQL Server 兼容模式下返回的是 + * PostgreSQL 行记录文本格式 {@code (1,"source text")} 而非纯文本。 + * 此方法剥离行记录包装,提取实际的源码内容。 + */ + static String unwrapRecordText(String source) { + if (source == null || source.isEmpty()) { + return source; + } + String trimmed = source.trim(); + if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) { + return source; + } + // 格式: (field1,field2,...),第一个字段通常是整数,第二个是源码文本 + String inner = trimmed.substring(1, trimmed.length() - 1); + int commaIdx = inner.indexOf(','); + if (commaIdx <= 0) { + // 单字段行记录: (source_text) + return unquoteRecordField(inner); + } + // 多字段行记录: 跳过第一个字段,提取第二个字段及之后的内容 + String rest = inner.substring(commaIdx + 1); + return unquoteRecordField(rest); + } + + private static String unquoteRecordField(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1).replace("\"\"", "\""); + } + return trimmed; + } +} diff --git a/agents/drivers/vastbase/src/test/java/com/dbx/agent/vastbase/VastbaseAgentTest.java b/agents/drivers/vastbase/src/test/java/com/dbx/agent/vastbase/VastbaseAgentTest.java new file mode 100644 index 0000000..ab8cb9a --- /dev/null +++ b/agents/drivers/vastbase/src/test/java/com/dbx/agent/vastbase/VastbaseAgentTest.java @@ -0,0 +1,26 @@ +package com.dbx.agent.vastbase; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class VastbaseAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new VastbaseAgent(); + } + + @Override + protected String resultSetSql() { + return "CALL sample_proc()"; + } + + @Test + void declaresVastbasePostgresLikeProfile() { + VastbaseAgent agent = new VastbaseAgent(); + + Assertions.assertEquals("cn.com.vastbase.Driver", agent.getProfile().getDriverClass()); + Assertions.assertEquals("jdbc:vastbase://{host}:{port}/{database}", agent.getProfile().getUrlTemplate()); + } +} diff --git a/agents/drivers/vertica/build.gradle b/agents/drivers/vertica/build.gradle new file mode 100644 index 0000000..fc1e0cf --- /dev/null +++ b/agents/drivers/vertica/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.vertica.jdbc:vertica-jdbc:25.3.0-0' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'Vertica', 'Main-Class': 'com.dbx.agent.vertica.VerticaAgent') + } +} diff --git a/agents/drivers/vertica/src/main/java/com/dbx/agent/vertica/VerticaAgent.java b/agents/drivers/vertica/src/main/java/com/dbx/agent/vertica/VerticaAgent.java new file mode 100644 index 0000000..05cef7a --- /dev/null +++ b/agents/drivers/vertica/src/main/java/com/dbx/agent/vertica/VerticaAgent.java @@ -0,0 +1,21 @@ +package com.dbx.agent.vertica; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class VerticaAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile VERTICA_PROFILE = new JdbcAgentProfile( + "com.vertica.jdbc.Driver", + "jdbc:vertica://{host}:{port}/{database}", + 5433 + ); + + public VerticaAgent() { + super(VERTICA_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new VerticaAgent()).run(); + } +} diff --git a/agents/drivers/xugu/README.md b/agents/drivers/xugu/README.md new file mode 100644 index 0000000..09631c3 --- /dev/null +++ b/agents/drivers/xugu/README.md @@ -0,0 +1,36 @@ +# DBX Xugu Agent + +Native XuguDB agent for DBX using `gitee.com/XuguDB/go-xugu-driver`. + +## Build + +```bash +go build -o agent . +``` + +Cross-compile release builds use pure Go output: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dbx-agent-xugu-linux-x64 . +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dbx-agent-xugu-macos-aarch64 . +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dbx-agent-xugu-windows-x64.exe . +``` + +## Local DBX Test + +Build the binary, then copy it into DBX's installed XuguDB driver directory: + +```bash +mkdir -p ~/.dbx/agents/drivers/xugu +cp agent ~/.dbx/agents/drivers/xugu/agent +chmod +x ~/.dbx/agents/drivers/xugu/agent +``` + +DBX prefers `agent` over `agent.jar`, so XuguDB connections will use this Go +agent until the file is removed. + +To restore the Java/JDBC agent: + +```bash +rm ~/.dbx/agents/drivers/xugu/agent +``` diff --git a/agents/drivers/xugu/go.mod b/agents/drivers/xugu/go.mod new file mode 100644 index 0000000..acc7048 --- /dev/null +++ b/agents/drivers/xugu/go.mod @@ -0,0 +1,5 @@ +module github.com/t8y2/dbx/agents/drivers/xugu + +go 1.22 + +require gitee.com/XuguDB/go-xugu-driver v1.0.12 diff --git a/agents/drivers/xugu/go.sum b/agents/drivers/xugu/go.sum new file mode 100644 index 0000000..7b5f9a2 --- /dev/null +++ b/agents/drivers/xugu/go.sum @@ -0,0 +1,2 @@ +gitee.com/XuguDB/go-xugu-driver v1.0.12 h1:Le49iCI5YJBmQDJUUWx2SHHR3aYio0CTFjNx5O8m2Ew= +gitee.com/XuguDB/go-xugu-driver v1.0.12/go.mod h1:VJlSyp+6BjYL1US00cVb4traVISlm8iGywYe3viLPbE= diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go new file mode 100644 index 0000000..3ee35ef --- /dev/null +++ b/agents/drivers/xugu/main.go @@ -0,0 +1,2052 @@ +package main + +import ( + "bufio" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + "sort" + "strconv" + "strings" + "time" + + _ "gitee.com/XuguDB/go-xugu-driver" +) + +const protocolVersion = 1 +const defaultMaxRows = 1000 +const defaultXuguPort = 5138 +const xuguListDatabasesSQL = ` +SELECT DB_NAME +FROM ALL_DATABASES +ORDER BY DB_NAME` +const xuguListSchemasSQL = ` +SELECT SCHEMA_NAME +FROM ALL_SCHEMAS +ORDER BY SCHEMA_NAME` +const xuguPrimaryKeyColumnsSQL = ` +SELECT c.DEFINE +FROM ALL_CONSTRAINTS c +JOIN ALL_TABLES t ON t.DB_ID = c.DB_ID AND t.TABLE_ID = c.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(t.TABLE_NAME) = UPPER(?) + AND c.CONS_TYPE = 'P'` +const xuguListColumnsSQL = ` +SELECT c.COL_NAME, c.TYPE_NAME, c.NOT_NULL, c.DEF_VAL, c.COMMENTS, c.SCALE, c."VARYING" +FROM ALL_COLUMNS c +JOIN ALL_TABLES t ON t.DB_ID = c.DB_ID AND t.TABLE_ID = c.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(t.TABLE_NAME) = UPPER(?) + AND (c.IS_HIDE IS NULL OR c.IS_HIDE = FALSE) +ORDER BY c.COL_NO` +const xuguListIndexesSQL = ` +SELECT i.INDEX_NAME, i.KEYS, i.IS_UNIQUE, i.IS_PRIMARY, i.INDEX_TYPE, i.FILTER +FROM ALL_INDEXES i +JOIN ALL_TABLES t ON t.DB_ID = i.DB_ID AND t.TABLE_ID = i.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(t.TABLE_NAME) = UPPER(?) +ORDER BY i.INDEX_NAME` + +var xuguDataTypes = []string{ + "BOOLEAN", + "INTEGER", + "SMALLINT", + "BIGINT", + "FLOAT", + "NUMERIC", + "CHAR", + "VARCHAR", + "CLOB", + "DATE", + "TIME", + "TIMESTAMP", + "BINARY", + "VARBINARY", + "BLOB", + "XML", + "BOOL", + "INT", + "SHORT", + "LONGINT", + "LONG", + "REAL", + "DECIMAL", + "TEXT", + "NCHAR", + "NVARCHAR", + "NVARCHAR2", +} + +type request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type connectParams struct { + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + SysDBA bool `json:"sysdba"` + URLParams string `json:"url_params"` + ConnectionString string `json:"connection_string"` +} + +type queryOptions struct { + SQL string `json:"sql"` + Database string `json:"database"` + Schema string `json:"schema"` + MaxRows int `json:"maxRows"` + FetchSize int `json:"fetchSize"` +} + +type queryResult struct { + Columns []string `json:"columns"` + ColumnTypes []string `json:"column_types"` + Rows [][]any `json:"rows"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMS int64 `json:"execution_time_ms"` + Truncated bool `json:"truncated"` +} + +func (r queryResult) MarshalJSON() ([]byte, error) { + type alias queryResult + value := alias(r) + if value.Columns == nil { + value.Columns = []string{} + } + if value.ColumnTypes == nil { + value.ColumnTypes = []string{} + } + if value.Rows == nil { + value.Rows = [][]any{} + } + return json.Marshal(value) +} + +type queryPageResult struct { + Columns []string `json:"columns"` + ColumnTypes []string `json:"column_types"` + Rows [][]any `json:"rows"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMS int64 `json:"execution_time_ms"` + Truncated bool `json:"truncated"` + SessionID *string `json:"session_id"` + HasMore bool `json:"has_more"` +} + +func (r queryPageResult) MarshalJSON() ([]byte, error) { + type alias queryPageResult + value := alias(r) + if value.Columns == nil { + value.Columns = []string{} + } + if value.ColumnTypes == nil { + value.ColumnTypes = []string{} + } + if value.Rows == nil { + value.Rows = [][]any{} + } + return json.Marshal(value) +} + +type querySession struct { + rows *sql.Rows + columns []string + columnTypes []string + pending []any + remaining int +} + +type databaseInfo struct { + Name string `json:"name"` +} + +type tableInfo struct { + Name string `json:"name"` + TableType string `json:"table_type"` + Comment *string `json:"comment"` +} + +type objectInfo struct { + Name string `json:"name"` + ObjectType string `json:"object_type"` + Schema string `json:"schema"` + Comment *string `json:"comment"` +} + +type metadataListConstraints struct { + Filter string + Limit int + Offset int + ObjectTypes []string +} + +type xuguMetadataListQuery struct { + SQL string + Args []any +} + +type columnInfo struct { + Name string `json:"name"` + DataType string `json:"data_type"` + IsNullable bool `json:"is_nullable"` + ColumnDefault *string `json:"column_default"` + IsPrimaryKey bool `json:"is_primary_key"` + Extra *string `json:"extra"` + Comment *string `json:"comment"` + NumericPrecision *int `json:"numeric_precision"` + NumericScale *int `json:"numeric_scale"` + CharacterMaximumLength *int `json:"character_maximum_length"` +} + +type indexInfo struct { + Name string `json:"name"` + Columns []string `json:"columns"` + IsUnique bool `json:"is_unique"` + IsPrimary bool `json:"is_primary"` + Filter *string `json:"filter"` + IndexType *string `json:"index_type"` + IncludedColumns []string `json:"included_columns"` + Comment *string `json:"comment"` +} + +func (i indexInfo) MarshalJSON() ([]byte, error) { + type alias indexInfo + value := alias(i) + if value.Columns == nil { + value.Columns = []string{} + } + if value.IncludedColumns == nil { + value.IncludedColumns = []string{} + } + return json.Marshal(value) +} + +type foreignKeyInfo struct { + Name string `json:"name"` + Column string `json:"column"` + RefTable string `json:"ref_table"` + RefColumn string `json:"ref_column"` +} + +type triggerInfo struct { + Name string `json:"name"` + Event string `json:"event"` + Timing string `json:"timing"` +} + +type server struct { + db *sql.DB + params connectParams + sessions map[string]*querySession + nextSessionID int64 +} + +func main() { + s := newServer() + encoder := json.NewEncoder(os.Stdout) + fmt.Fprintln(os.Stdout, `{"ready":true}`) + + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + resp, shutdown := s.handleLine(line) + if err := encoder.Encode(resp); err != nil { + fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err) + return + } + if shutdown { + return + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { + fmt.Fprintf(os.Stderr, "failed to read stdin: %v\n", err) + } +} + +func newServer() *server { + return &server{sessions: map[string]*querySession{}} +} + +func (s *server) handleLine(line string) (response, bool) { + var req request + if err := json.Unmarshal([]byte(line), &req); err != nil { + return errorResponse(nil, err), false + } + if len(req.ID) == 0 { + req.ID = json.RawMessage("1") + } + result, shutdown, err := s.dispatch(req.Method, req.Params) + if err != nil { + return errorResponse(req.ID, err), false + } + return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown +} + +func (s *server) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) { + switch method { + case "handshake": + return map[string]any{ + "protocolVersion": protocolVersion, + "agentProtocolVersion": protocolVersion, + "capabilities": []string{"connect", "test_connection", "metadata", "query", "ddl"}, + }, false, nil + case "connect": + var cp connectParams + if err := decodeParams(params, &cp); err != nil { + return nil, false, err + } + return map[string]bool{"ok": true}, false, s.connect(cp) + case "test_connection": + var cp connectParams + if err := decodeParams(params, &cp); err != nil { + return nil, false, err + } + db, err := openDB(cp) + if err != nil { + return nil, false, err + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return nil, false, err + } + return map[string]bool{"ok": true}, false, nil + case "list_databases": + result, err := s.listDatabases() + return result, false, err + case "list_schemas": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + result, err := s.listSchemas() + return result, false, err + case "list_tables": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + result, err := s.listTables(schema, metadataListConstraintsFromParams(params)) + return result, false, err + case "list_objects": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + result, err := s.listObjects(schema, metadataListConstraintsFromParams(params)) + return result, false, err + case "list_data_types": + return xuguDataTypes, false, nil + case "get_columns": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.getColumns(schema, table) + return result, false, err + case "get_object_source": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + name := stringParam(params, "name") + objectType := stringParam(params, "object_type") + source, err := s.getObjectSource(schema, name, objectType) + return source, false, err + case "get_table_ddl": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + table := stringParam(params, "table") + ddl, err := s.getTableDDL(schema, table) + return ddl, false, err + case "execute_query": + var opts queryOptions + if err := decodeParams(params, &opts); err != nil { + return nil, false, err + } + result, err := s.executeQuery(opts) + return result, false, err + case "execute_query_page": + var opts queryOptions + if err := decodeParams(params, &opts); err != nil { + return nil, false, err + } + result, err := s.executeQueryPage(opts, intParam(params, "pageSize")) + return result, false, err + case "fetch_query_page": + result, err := s.fetchQueryPage(stringParam(params, "sessionId"), intParam(params, "pageSize")) + return result, false, err + case "close_query_session": + return s.closeQuerySession(stringParam(params, "sessionId")), false, nil + case "list_indexes": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listIndexes(schema, table) + return result, false, err + case "list_foreign_keys": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listForeignKeys(schema, table) + return result, false, err + case "list_triggers": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listTriggers(schema, table) + return result, false, err + case "get_explain_info": + sqlText := stringParam(params, "sql") + plan, err := s.getExplainInfo(sqlText) + return map[string]any{"plan": plan, "has_actual_stats": false}, false, err + case "execute_transaction": + result, err := s.executeTransaction(params) + return result, false, err + case "disconnect": + return map[string]bool{"ok": true}, false, s.disconnect() + case "shutdown": + _ = s.disconnect() + return map[string]bool{"ok": true}, true, nil + default: + return nil, false, fmt.Errorf("unknown method: %s", method) + } +} + +func (s *server) connect(params connectParams) error { + _ = s.disconnect() + db, err := openDB(params) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + db.Close() + return err + } + s.db = db + s.params = params + return nil +} + +func (s *server) disconnect() error { + s.closeAllQuerySessions() + if s.db == nil { + return nil + } + err := s.db.Close() + s.db = nil + return err +} + +func openDB(params connectParams) (*sql.DB, error) { + dsn := buildDSN(params) + db, err := sql.Open("xugu", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(30 * time.Minute) + return db, nil +} + +func buildDSN(params connectParams) string { + connectionString := strings.TrimSpace(params.ConnectionString) + if looksLikeXuguDSN(connectionString) { + return connectionString + } + if parsed := parseXuguURL(connectionString); parsed.Host != "" { + if parsed.Database == "" { + parsed.Database = params.Database + } + if parsed.Username == "" { + parsed.Username = params.Username + } + if parsed.Password == "" { + parsed.Password = params.Password + } + return buildXuguDSN(parsed.Host, parsed.Port, parsed.Database, parsed.Username, parsed.Password, params.URLParams) + } + + if jdbc := parseXuguJDBCURL(connectionString); jdbc.Host != "" { + return buildXuguDSN(jdbc.Host, jdbc.Port, jdbc.Database, params.Username, params.Password, params.URLParams) + } + + return buildXuguDSN(params.Host, params.Port, params.Database, params.Username, params.Password, params.URLParams) +} + +func looksLikeXuguDSN(value string) bool { + upper := strings.ToUpper(value) + return strings.Contains(upper, "IP=") && strings.Contains(upper, "DB=") && strings.Contains(upper, "USER=") +} + +type xuguURLInfo struct { + Host string + Port int + Database string + Username string + Password string +} + +var xuguJDBCRegexp = regexp.MustCompile(`(?i)^jdbc:xugu://([^/:]+)(?::([0-9]+))?/([^?;]+)`) + +func parseXuguJDBCURL(value string) xuguURLInfo { + value = strings.TrimSpace(value) + match := xuguJDBCRegexp.FindStringSubmatch(value) + if len(match) != 4 { + return xuguURLInfo{} + } + return xuguURLInfo{Host: match[1], Port: parsePort(match[2]), Database: match[3]} +} + +func parseXuguURL(value string) xuguURLInfo { + value = strings.TrimSpace(value) + if !strings.HasPrefix(strings.ToLower(value), "xugu://") { + return xuguURLInfo{} + } + withoutScheme := value[len("xugu://"):] + var userInfo string + hostPart := withoutScheme + if at := strings.LastIndex(hostPart, "@"); at >= 0 { + userInfo = hostPart[:at] + hostPart = hostPart[at+1:] + } + if slash := strings.IndexAny(hostPart, "/?"); slash >= 0 { + databasePart := strings.TrimLeft(hostPart[slash:], "/") + hostPart = hostPart[:slash] + if q := strings.Index(databasePart, "?"); q >= 0 { + databasePart = databasePart[:q] + } + info := parseHostPort(hostPart) + info.Database = databasePart + if userInfo != "" { + info.Username, info.Password = splitUserInfo(userInfo) + } + return info + } + info := parseHostPort(hostPart) + if userInfo != "" { + info.Username, info.Password = splitUserInfo(userInfo) + } + return info +} + +func parseHostPort(value string) xuguURLInfo { + host := strings.TrimSpace(value) + port := 0 + if idx := strings.LastIndex(host, ":"); idx > 0 { + port = parsePort(host[idx+1:]) + host = host[:idx] + } + return xuguURLInfo{Host: host, Port: port} +} + +func splitUserInfo(value string) (string, string) { + if idx := strings.Index(value, ":"); idx >= 0 { + return value[:idx], value[idx+1:] + } + return value, "" +} + +func buildXuguDSN(host string, port int, database, username, password, urlParams string) string { + if port <= 0 { + port = defaultXuguPort + } + parts := []string{ + "IP=" + host, + "DB=" + strings.TrimSpace(database), + "User=" + strings.TrimSpace(username), + "PWD=" + strings.TrimSpace(password), + "Port=" + strconv.Itoa(port), + } + if !hasDSNParam(urlParams, "CHAR_SET") && !hasDSNParam(urlParams, "CHARSET") { + parts = append(parts, "CHAR_SET=UTF8") + } + for _, param := range splitDSNParams(urlParams) { + parts = append(parts, param) + } + return strings.Join(parts, ";") +} + +func hasDSNParam(raw, key string) bool { + key = strings.ToUpper(strings.TrimSpace(key)) + for _, param := range splitDSNParams(raw) { + name, _, _ := strings.Cut(param, "=") + if strings.ToUpper(strings.TrimSpace(name)) == key { + return true + } + } + return false +} + +func splitDSNParams(raw string) []string { + raw = strings.TrimSpace(strings.Trim(raw, ";")) + if raw == "" { + return nil + } + if strings.Contains(raw, ";") { + items := strings.Split(raw, ";") + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + return result + } + if strings.Contains(raw, "&") { + items := strings.Split(raw, "&") + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + return result + } + return []string{raw} +} + +func parsePort(value string) int { + port, _ := strconv.Atoi(value) + return port +} + +func (s *server) requireDB() (*sql.DB, error) { + if s.db == nil { + return nil, errors.New("agent is not connected") + } + return s.db, nil +} + +func (s *server) useDatabase(database string) error { + database = strings.TrimSpace(database) + if database == "" { + return nil + } + if strings.EqualFold(database, configuredDatabaseName(s.params)) { + return nil + } + db, err := s.requireDB() + if err != nil { + return err + } + _, err = db.Exec("USE " + quoteIdentifier(database)) + return err +} + +func (s *server) listDatabases() ([]databaseInfo, error) { + rows, err := s.queryRows(xuguListDatabasesSQL, nil) + if err != nil { + if fallback := fallbackDatabasesFromParams(s.params); len(fallback) > 0 && isXuguMetadataAccessError(err) { + return fallback, nil + } + return nil, err + } + defer rows.Close() + var result []databaseInfo + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + result = append(result, databaseInfo{Name: name}) + } + return emptyIfNil(result), rows.Err() +} + +func fallbackDatabasesFromParams(params connectParams) []databaseInfo { + if name := configuredDatabaseName(params); name != "" { + return []databaseInfo{{Name: name}} + } + return nil +} + +func configuredDatabaseName(params connectParams) string { + if name := strings.TrimSpace(params.Database); name != "" { + return name + } + connectionString := strings.TrimSpace(params.ConnectionString) + if parsed := parseXuguURL(connectionString); parsed.Database != "" { + return parsed.Database + } + if jdbc := parseXuguJDBCURL(connectionString); jdbc.Database != "" { + return jdbc.Database + } + if value := xuguDSNValue(connectionString, "DB"); value != "" { + return value + } + return "" +} + +func isXuguMetadataAccessError(err error) bool { + message := strings.ToUpper(err.Error()) + return strings.Contains(message, "E18012") || + strings.Contains(message, "权限不够") || + strings.Contains(message, "ALL_DATABASES") || + strings.Contains(message, "SYS_DATABASES") || + strings.Contains(message, "ALL_SCHEMAS") || + strings.Contains(message, "SYS_SCHEMAS") || + strings.Contains(message, "ALL_TABLES") || + strings.Contains(message, "SYS_TABLES") || + strings.Contains(message, "ALL_VIEWS") || + strings.Contains(message, "SYS_VIEWS") || + strings.Contains(message, "ALL_COLUMNS") || + strings.Contains(message, "ALL_CONSTRAINTS") || + strings.Contains(message, "ALL_INDEXES") || + strings.Contains(message, "SYS_COLUMNS") || + strings.Contains(message, "SYS_CONSTRAINTS") || + strings.Contains(message, "SYS_INDEXES") || + strings.Contains(message, "SYS_TRIGGERS") +} + +func xuguDSNValue(dsn string, key string) string { + for _, part := range strings.Split(dsn, ";") { + name, value, ok := strings.Cut(part, "=") + if ok && strings.EqualFold(strings.TrimSpace(name), key) { + return strings.TrimSpace(value) + } + } + return "" +} + +func (s *server) listSchemas() ([]string, error) { + rows, err := s.queryRows(xuguListSchemasSQL, nil) + if err != nil { + if fallback := strings.ToUpper(strings.TrimSpace(s.params.Username)); fallback != "" && isXuguMetadataAccessError(err) { + return []string{fallback}, nil + } + return nil, err + } + defer rows.Close() + var result []string + for rows.Next() { + var schema string + if err := rows.Scan(&schema); err != nil { + return nil, err + } + result = append(result, schema) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) currentSchema() (string, error) { + rows, err := s.queryRows(` +SELECT s.SCHEMA_NAME +FROM SYS_SCHEMAS s +JOIN SYS_USERS u ON u.DB_ID = s.DB_ID AND u.USER_ID = s.USER_ID +WHERE UPPER(u.USER_NAME) = UPPER(?) +ORDER BY CASE WHEN UPPER(s.SCHEMA_NAME) = UPPER(?) THEN 0 ELSE 1 END, s.SCHEMA_NAME`, []any{s.params.Username, s.params.Username}) + if err != nil { + if fallback := strings.ToUpper(strings.TrimSpace(s.params.Username)); fallback != "" && isXuguMetadataAccessError(err) { + return fallback, nil + } + return "", err + } + defer rows.Close() + if rows.Next() { + var schema string + if err := rows.Scan(&schema); err != nil { + return "", err + } + return strings.ToUpper(schema), nil + } + if err := rows.Err(); err != nil { + return "", err + } + return strings.ToUpper(strings.TrimSpace(s.params.Username)), nil +} + +func (s *server) normalizeSchema(schema string) (string, error) { + schema = strings.TrimSpace(schema) + if schema == "" { + return s.currentSchema() + } + return strings.ToUpper(schema), nil +} + +func (s *server) listTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + query := xuguListTablesQuery(schema, constraints) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + return nil, err + } + defer rows.Close() + var result []tableInfo + for rows.Next() { + var item tableInfo + if err := rows.Scan(&item.Name, &item.TableType, &item.Comment); err != nil { + return nil, err + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + query := xuguListObjectsQuery(schema, constraints) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + return nil, err + } + defer rows.Close() + var result []objectInfo + for rows.Next() { + var item objectInfo + item.Schema = schema + if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment); err != nil { + return nil, err + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func metadataListConstraintsFromParams(params map[string]json.RawMessage) metadataListConstraints { + objectTypes := stringSliceParam(params, "object_types") + if len(objectTypes) == 0 { + objectTypes = stringSliceParam(params, "objectTypes") + } + limit := intParam(params, "limit") + offset := intParam(params, "offset") + if limit < 0 { + limit = 0 + } + if offset < 0 { + offset = 0 + } + return metadataListConstraints{ + Filter: stringParam(params, "filter"), + Limit: limit, + Offset: offset, + ObjectTypes: objectTypes, + } +} + +func xuguListTablesQuery(schema string, constraints metadataListConstraints) xuguMetadataListQuery { + return xuguConstrainedMetadataListQuery( + ` +SELECT t.TABLE_NAME, 'TABLE' AS TABLE_TYPE, t.COMMENTS +FROM ALL_TABLES t +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) +UNION ALL +SELECT v.VIEW_NAME, 'VIEW' AS TABLE_TYPE, v.COMMENTS +FROM ALL_VIEWS v +JOIN ALL_SCHEMAS s ON s.DB_ID = v.DB_ID AND s.SCHEMA_ID = v.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)`, + "TABLE_NAME, TABLE_TYPE, COMMENTS", + "TABLE_NAME", + "TABLE_TYPE", + []any{schema, schema}, + constraints, + ) +} + +func xuguListObjectsQuery(schema string, constraints metadataListConstraints) xuguMetadataListQuery { + return xuguConstrainedMetadataListQuery( + ` +SELECT t.TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE, t.COMMENTS +FROM ALL_TABLES t +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) +UNION ALL +SELECT v.VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE, v.COMMENTS +FROM ALL_VIEWS v +JOIN ALL_SCHEMAS s ON s.DB_ID = v.DB_ID AND s.SCHEMA_ID = v.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)`, + "OBJECT_NAME, OBJECT_TYPE, COMMENTS", + "OBJECT_NAME", + "OBJECT_TYPE", + []any{schema, schema}, + constraints, + ) +} + +func xuguConstrainedMetadataListQuery(baseSQL, selectList, nameColumn, typeColumn string, baseArgs []any, constraints metadataListConstraints) xuguMetadataListQuery { + args := append([]any{}, baseArgs...) + where := make([]string, 0, 2) + if filter := strings.TrimSpace(constraints.Filter); filter != "" { + args = append(args, strings.ToUpper(xuguFuzzyLikePattern(filter))) + where = append(where, fmt.Sprintf("UPPER(%s) LIKE ? ESCAPE '\\'", nameColumn)) + } + if len(constraints.ObjectTypes) > 0 { + objectTypes := normalizedXuguObjectTypes(constraints.ObjectTypes) + if len(objectTypes) == 0 { + where = append(where, "1 = 0") + } else { + placeholders := make([]string, 0, len(objectTypes)) + for _, objectType := range objectTypes { + args = append(args, objectType) + placeholders = append(placeholders, "?") + } + where = append(where, fmt.Sprintf("%s IN (%s)", typeColumn, strings.Join(placeholders, ","))) + } + } + + sqlText := fmt.Sprintf("SELECT %s\nFROM (\n%s\n)", selectList, baseSQL) + if len(where) > 0 { + sqlText += "\nWHERE " + strings.Join(where, " AND ") + } + sqlText += fmt.Sprintf("\nORDER BY %s, %s", typeColumn, nameColumn) + + // Xugu documents ROWNUM as the safe pagination path when ORDER BY belongs + // to an inner query; LIMIT is not portable for this UNION metadata query. + if constraints.Limit > 0 { + args = append(args, constraints.Offset+constraints.Limit, constraints.Offset) + sqlText = fmt.Sprintf( + "SELECT %s\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n%s\n ) DBX_Q\n WHERE ROWNUM <= ?\n)\nWHERE DBX_RN > ?", + selectList, + sqlText, + ) + } else if constraints.Offset > 0 { + args = append(args, constraints.Offset) + sqlText = fmt.Sprintf( + "SELECT %s\nFROM (\n SELECT DBX_Q.*, ROWNUM AS DBX_RN\n FROM (\n%s\n ) DBX_Q\n)\nWHERE DBX_RN > ?", + selectList, + sqlText, + ) + } + + return xuguMetadataListQuery{SQL: sqlText, Args: args} +} + +func normalizedXuguObjectTypes(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.ToUpper(strings.TrimSpace(value)) + normalized = strings.ReplaceAll(normalized, "-", "_") + normalized = strings.ReplaceAll(normalized, " ", "_") + switch normalized { + case "TABLE", "BASE_TABLE": + normalized = "TABLE" + case "VIEW": + normalized = "VIEW" + default: + continue + } + if seen[normalized] { + continue + } + seen[normalized] = true + result = append(result, normalized) + } + sort.Strings(result) + return result +} + +func xuguFuzzyLikePattern(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "%%" + } + var builder strings.Builder + builder.Grow(len(value)*2 + 2) + builder.WriteByte('%') + for _, ch := range value { + switch ch { + case '\\', '%', '_': + builder.WriteByte('\\') + } + builder.WriteRune(ch) + builder.WriteByte('%') + } + return builder.String() +} + +func (s *server) getColumns(schema, table string) ([]columnInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + primaryKeys, err := s.primaryKeyColumns(schema, table) + if err != nil { + return nil, err + } + rows, err := s.queryRows(xuguListColumnsSQL, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return s.columnsFromSelect(schema, table, primaryKeys) + } + return nil, err + } + defer rows.Close() + var result []columnInfo + for rows.Next() { + var item columnInfo + var notNull any + var scale *int + var varying any + if err := rows.Scan( + &item.Name, + &item.DataType, + ¬Null, + &item.ColumnDefault, + &item.Comment, + &scale, + &varying, + ); err != nil { + return nil, err + } + item.DataType = normalizeXuguColumnType(item.DataType, varying) + item.IsNullable = !truthy(notNull) + item.IsPrimaryKey = primaryKeys[strings.ToUpper(item.Name)] + item.NumericPrecision, item.NumericScale, item.CharacterMaximumLength = decodeXuguScale(item.DataType, scale) + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) columnsFromSelect(schema, table string, primaryKeys map[string]bool) ([]columnInfo, error) { + rows, err := s.queryRows( + "SELECT * FROM "+quoteIdentifier(schema)+"."+quoteIdentifier(table)+" WHERE 1 = 0", + nil, + ) + if err != nil { + return nil, err + } + defer rows.Close() + types, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + result := make([]columnInfo, 0, len(types)) + for _, columnType := range types { + item := columnInfo{ + Name: columnType.Name(), + DataType: columnType.DatabaseTypeName(), + IsPrimaryKey: primaryKeys[strings.ToUpper(columnType.Name())], + } + if nullable, ok := columnType.Nullable(); ok { + item.IsNullable = nullable + } else { + item.IsNullable = true + } + if length, ok := columnType.Length(); ok { + value := int(length) + item.CharacterMaximumLength = &value + } + result = append(result, item) + } + return emptyIfNil(result), nil +} + +func (s *server) primaryKeyColumns(schema, table string) (map[string]bool, error) { + rows, err := s.queryRows(xuguPrimaryKeyColumnsSQL, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return map[string]bool{}, nil + } + return nil, err + } + defer rows.Close() + result := map[string]bool{} + for rows.Next() { + var define string + if err := rows.Scan(&define); err != nil { + return nil, err + } + for _, column := range parseQuotedIdentifiers(define) { + result[strings.ToUpper(column)] = true + } + } + return result, rows.Err() +} + +func (s *server) listIndexes(schema, table string) ([]indexInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(xuguListIndexesSQL, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return []indexInfo{}, nil + } + return nil, err + } + defer rows.Close() + + var result []indexInfo + for rows.Next() { + var item indexInfo + var keys string + var unique, primary any + var indexType any + if err := rows.Scan(&item.Name, &keys, &unique, &primary, &indexType, &item.Filter); err != nil { + return nil, err + } + item.Columns = parseIndexKeys(keys) + item.IsUnique = truthy(unique) + item.IsPrimary = truthy(primary) + item.IndexType = stringPtr(indexTypeName(indexType)) + item.IncludedColumns = []string{} + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listForeignKeys(schema, table string) ([]foreignKeyInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT c.CONS_NAME, c.DEFINE, rt.TABLE_NAME +FROM SYS_CONSTRAINTS c +JOIN SYS_TABLES t ON t.DB_ID = c.DB_ID AND t.TABLE_ID = c.TABLE_ID +JOIN SYS_TABLES rt ON rt.DB_ID = c.DB_ID AND rt.TABLE_ID = c.REF_TABLE_ID +JOIN SYS_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(t.TABLE_NAME) = UPPER(?) + AND c.CONS_TYPE = 'F' +ORDER BY c.CONS_NAME`, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return []foreignKeyInfo{}, nil + } + return nil, err + } + defer rows.Close() + var result []foreignKeyInfo + for rows.Next() { + var name, define, refTable string + if err := rows.Scan(&name, &define, &refTable); err != nil { + return nil, err + } + local, ref := parseForeignKeyColumns(define) + for i, column := range local { + item := foreignKeyInfo{Name: name, Column: column, RefTable: refTable} + if i < len(ref) { + item.RefColumn = ref[i] + } + result = append(result, item) + } + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(` +SELECT tr.TRIG_NAME, tr.TRIG_EVENT, tr.TRIG_TIME +FROM SYS_TRIGGERS tr +JOIN SYS_TABLES t ON t.DB_ID = tr.DB_ID AND t.TABLE_ID = tr.OBJ_ID +JOIN SYS_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(t.TABLE_NAME) = UPPER(?) +ORDER BY tr.TRIG_NAME`, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return []triggerInfo{}, nil + } + return nil, err + } + defer rows.Close() + var result []triggerInfo + for rows.Next() { + var item triggerInfo + var event, timing any + if err := rows.Scan(&item.Name, &event, &timing); err != nil { + return nil, err + } + item.Event = triggerEventName(event) + item.Timing = triggerTimingName(timing) + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) { + var err error + schema, err = s.normalizeSchema(schema) + if err != nil { + return nil, err + } + sourceSQL, args, err := objectSourceQuery(schema, name, objectType) + if err != nil { + return nil, err + } + rows, err := s.queryRows(sourceSQL, args) + if err != nil { + return nil, err + } + defer rows.Close() + var builder strings.Builder + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + return nil, err + } + builder.WriteString(line) + } + return map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": builder.String()}, rows.Err() +} + +func (s *server) getTableDDL(schema, table string) (string, error) { + var err error + schema, err = s.normalizeSchema(schema) + if err != nil { + return "", err + } + var ddl string + rows, err := s.queryRows("SELECT TO_CHAR(DBMS_METADATA.GET_DDL('TABLE', ?, ?)) FROM DUAL", []any{strings.ToUpper(table), schema}) + if err == nil { + defer rows.Close() + if rows.Next() { + if scanErr := rows.Scan(&ddl); scanErr == nil && strings.TrimSpace(ddl) != "" { + if err := rows.Err(); err != nil { + return "", err + } + return s.appendTableIndexDDL(schema, table, ddl), nil + } + } + } + ddl, err = s.buildTableDDL(schema, table) + if err != nil { + return "", err + } + return s.appendTableIndexDDL(schema, table, ddl), nil +} + +func (s *server) getExplainInfo(sqlText string) (string, error) { + if strings.TrimSpace(sqlText) == "" { + return "", errors.New("sql is required") + } + rows, err := s.queryRows("EXPLAIN "+trimStatementSQL(sqlText), nil) + if err != nil { + return "", err + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return "", err + } + var builder strings.Builder + for rows.Next() { + values, err := scanRow(rows, len(columns)) + if err != nil { + return "", err + } + builder.WriteString(joinValues(values, "\t")) + builder.WriteByte('\n') + } + return strings.TrimSpace(builder.String()), rows.Err() +} + +func (s *server) executeTransaction(params map[string]json.RawMessage) (queryResult, error) { + var payload struct { + Statements []string `json:"statements"` + Schema string `json:"schema"` + } + if err := decodeParams(params, &payload); err != nil { + return queryResult{}, err + } + db, err := s.requireDB() + if err != nil { + return queryResult{}, err + } + tx, err := db.Begin() + if err != nil { + return queryResult{}, err + } + if strings.TrimSpace(payload.Schema) != "" { + if _, err := tx.Exec("SET SCHEMA " + quoteIdentifier(payload.Schema)); err != nil { + tx.Rollback() + return queryResult{}, err + } + } + var affected int64 + start := time.Now() + for _, statement := range payload.Statements { + statement = trimStatementSQL(statement) + if statement == "" { + continue + } + result, err := tx.Exec(statement) + if err != nil { + tx.Rollback() + return queryResult{}, err + } + count, _ := result.RowsAffected() + affected += count + } + if err := tx.Commit(); err != nil { + return queryResult{}, err + } + return queryResult{ + Columns: []string{}, + ColumnTypes: []string{}, + Rows: [][]any{}, + AffectedRows: affected, + ExecutionTimeMS: time.Since(start).Milliseconds(), + }, nil +} + +func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageResult, error) { + start := time.Now() + if err := s.useDatabase(opts.Database); err != nil { + return queryPageResult{}, err + } + if strings.TrimSpace(opts.Schema) != "" { + if err := s.setSchema(opts.Schema); err != nil { + return queryPageResult{}, err + } + } + sqlText := trimStatementSQL(opts.SQL) + if !isQuerySQL(sqlText) { + result, err := s.executeQuery(opts) + return queryPageResult{ + Columns: result.Columns, + ColumnTypes: result.ColumnTypes, + Rows: result.Rows, + AffectedRows: result.AffectedRows, + ExecutionTimeMS: result.ExecutionTimeMS, + Truncated: result.Truncated, + SessionID: nil, + HasMore: false, + }, err + } + rows, err := s.queryRows(sqlText, nil) + if err != nil { + return queryPageResult{}, err + } + columns, err := rows.Columns() + if err != nil { + rows.Close() + return queryPageResult{}, err + } + columnTypes := columnTypeNames(rows) + maxRows := opts.MaxRows + if maxRows <= 0 { + maxRows = defaultMaxRows + } + session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows} + result, err := readQuerySessionPage(session, pageSize) + result.ExecutionTimeMS = time.Since(start).Milliseconds() + if err != nil { + rows.Close() + return queryPageResult{}, err + } + if result.HasMore { + sessionID := s.storeQuerySession(session) + result.SessionID = &sessionID + } else { + rows.Close() + } + return result, nil +} + +func (s *server) fetchQueryPage(sessionID string, pageSize int) (queryPageResult, error) { + session := s.sessions[sessionID] + if session == nil { + return queryPageResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, SessionID: nil, HasMore: false}, nil + } + result, err := readQuerySessionPage(session, pageSize) + if err != nil { + s.closeQuerySession(sessionID) + return queryPageResult{}, err + } + if result.HasMore { + result.SessionID = &sessionID + } else { + s.closeQuerySession(sessionID) + } + return result, nil +} + +func (s *server) storeQuerySession(session *querySession) string { + s.nextSessionID++ + sessionID := fmt.Sprintf("xugu-%d", s.nextSessionID) + s.sessions[sessionID] = session + return sessionID +} + +func (s *server) closeQuerySession(sessionID string) bool { + session := s.sessions[sessionID] + if session == nil { + return false + } + session.rows.Close() + delete(s.sessions, sessionID) + return true +} + +func (s *server) closeAllQuerySessions() { + for sessionID := range s.sessions { + s.closeQuerySession(sessionID) + } +} + +func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) { + if pageSize <= 0 { + pageSize = defaultMaxRows + } + result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false} + for len(result.Rows) < pageSize && session.remaining > 0 { + if session.pending != nil { + result.Rows = append(result.Rows, session.pending) + session.pending = nil + session.remaining-- + continue + } + if !session.rows.Next() { + return result, session.rows.Err() + } + row, err := scanRow(session.rows, len(session.columns)) + if err != nil { + return queryPageResult{}, err + } + result.Rows = append(result.Rows, row) + session.remaining-- + } + if session.remaining <= 0 { + result.Truncated = true + return result, nil + } + if session.rows.Next() { + row, err := scanRow(session.rows, len(session.columns)) + if err != nil { + return queryPageResult{}, err + } + session.pending = row + result.HasMore = true + return result, nil + } + return result, session.rows.Err() +} + +func (s *server) executeQuery(opts queryOptions) (queryResult, error) { + start := time.Now() + if err := s.useDatabase(opts.Database); err != nil { + return queryResult{}, err + } + if strings.TrimSpace(opts.Schema) != "" { + if err := s.setSchema(opts.Schema); err != nil { + return queryResult{}, err + } + } + sqlText := trimStatementSQL(opts.SQL) + maxRows := opts.MaxRows + if maxRows <= 0 { + maxRows = defaultMaxRows + } + if isQuerySQL(sqlText) { + result, err := s.executeSelect(sqlText, maxRows) + result.ExecutionTimeMS = time.Since(start).Milliseconds() + return result, err + } + db, err := s.requireDB() + if err != nil { + return queryResult{}, err + } + execResult, err := db.Exec(sqlText) + if err != nil { + return queryResult{}, err + } + affected, _ := execResult.RowsAffected() + return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds()}, nil +} + +func (s *server) executeSelect(sqlText string, maxRows int) (queryResult, error) { + rows, err := s.queryRows(sqlText, nil) + if err != nil { + return queryResult{}, err + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return queryResult{}, err + } + result := queryResult{Columns: columns, ColumnTypes: columnTypeNames(rows), Rows: [][]any{}} + for rows.Next() { + if len(result.Rows) >= maxRows { + result.Truncated = true + break + } + values, err := scanRow(rows, len(columns)) + if err != nil { + return queryResult{}, err + } + result.Rows = append(result.Rows, values) + } + return result, rows.Err() +} + +func scanRow(rows *sql.Rows, columnCount int) ([]any, error) { + values := make([]any, columnCount) + scanTargets := make([]any, columnCount) + for i := range values { + scanTargets[i] = &values[i] + } + if err := rows.Scan(scanTargets...); err != nil { + return nil, err + } + for i, value := range values { + values[i] = normalizeValue(value) + } + return values, nil +} + +func columnTypeNames(rows *sql.Rows) []string { + types, err := rows.ColumnTypes() + if err != nil { + return []string{} + } + result := make([]string, 0, len(types)) + for _, columnType := range types { + result = append(result, columnType.DatabaseTypeName()) + } + return result +} + +func (s *server) setSchema(schema string) error { + db, err := s.requireDB() + if err != nil { + return err + } + _, err = db.Exec("SET SCHEMA " + quoteIdentifier(schema)) + return err +} + +func (s *server) queryRows(sqlText string, args []any) (*sql.Rows, error) { + db, err := s.requireDB() + if err != nil { + return nil, err + } + if len(args) == 0 { + return db.Query(sqlText) + } + return db.Query(sqlText, args...) +} + +func objectSourceQuery(schema, name, objectType string) (string, []any, error) { + objectType = strings.ToUpper(strings.TrimSpace(objectType)) + name = strings.ToUpper(strings.TrimSpace(name)) + switch objectType { + case "VIEW": + return ` +SELECT TO_CHAR(v.DEFINE) +FROM SYS_VIEWS v +JOIN SYS_SCHEMAS s ON s.DB_ID = v.DB_ID AND s.SCHEMA_ID = v.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(v.VIEW_NAME) = UPPER(?)`, []any{schema, name}, nil + case "TRIGGER": + return ` +SELECT TO_CHAR(t.DEFINE) +FROM SYS_TRIGGERS t +JOIN SYS_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(t.TRIG_NAME) = UPPER(?)`, []any{schema, name}, nil + case "PROCEDURE", "FUNCTION": + return ` +SELECT TO_CHAR(p.DEFINE) +FROM SYS_PROCEDURES p +JOIN SYS_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(p.PROC_NAME) = UPPER(?)`, []any{schema, name}, nil + case "PACKAGE", "PACKAGE BODY": + return ` +SELECT COALESCE(TO_CHAR(k.SPEC), '') || COALESCE(TO_CHAR(k.BODY), '') +FROM SYS_PACKAGES k +JOIN SYS_SCHEMAS s ON s.DB_ID = k.DB_ID AND s.SCHEMA_ID = k.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(k.PACK_NAME) = UPPER(?)`, []any{schema, name}, nil + default: + return "", nil, fmt.Errorf("object source is not supported for %s", objectType) + } +} + +func (s *server) buildTableDDL(schema, table string) (string, error) { + columns, err := s.getColumns(schema, table) + if err != nil { + return "", err + } + if len(columns) == 0 { + return "", fmt.Errorf("table not found: %s.%s", schema, table) + } + var builder strings.Builder + builder.WriteString("CREATE TABLE ") + builder.WriteString(quoteIdentifier(schema)) + builder.WriteByte('.') + builder.WriteString(quoteIdentifier(table)) + builder.WriteString(" (\n") + for i, column := range columns { + if i > 0 { + builder.WriteString(",\n") + } + builder.WriteString(" ") + builder.WriteString(quoteIdentifier(column.Name)) + builder.WriteByte(' ') + builder.WriteString(columnTypeDDL(column)) + if column.ColumnDefault != nil && strings.TrimSpace(*column.ColumnDefault) != "" { + builder.WriteString(" DEFAULT ") + builder.WriteString(strings.TrimSpace(*column.ColumnDefault)) + } + if !column.IsNullable { + builder.WriteString(" NOT NULL") + } + if column.Comment != nil && strings.TrimSpace(*column.Comment) != "" { + builder.WriteString(" COMMENT ") + builder.WriteString(quoteStringLiteral(strings.TrimSpace(*column.Comment))) + } + } + primary := make([]string, 0) + for _, column := range columns { + if column.IsPrimaryKey { + primary = append(primary, quoteIdentifier(column.Name)) + } + } + if len(primary) > 0 { + builder.WriteString(",\n PRIMARY KEY (") + builder.WriteString(strings.Join(primary, ", ")) + builder.WriteByte(')') + } + builder.WriteString("\n)") + if comment, err := s.tableComment(schema, table); err == nil && strings.TrimSpace(comment) != "" { + builder.WriteString("\nCOMMENT ") + builder.WriteString(quoteStringLiteral(strings.TrimSpace(comment))) + } + return builder.String(), nil +} + +func (s *server) appendTableIndexDDL(schema, table, ddl string) string { + indexDDL, err := s.tableIndexDDL(schema, table) + if err == nil && strings.TrimSpace(indexDDL) != "" { + return appendDDLStatement(ddl, indexDDL) + } + indexes, err := s.listIndexes(schema, table) + if err != nil || len(indexes) == 0 { + return ddl + } + var builder strings.Builder + for _, index := range indexes { + if index.IsPrimary || len(index.Columns) == 0 { + continue + } + if builder.Len() > 0 { + builder.WriteString("\n") + } + if index.IsUnique { + builder.WriteString("CREATE UNIQUE INDEX ") + } else { + builder.WriteString("CREATE INDEX ") + } + builder.WriteString(quoteIdentifier(index.Name)) + builder.WriteString(" ON ") + builder.WriteString(quoteIdentifier(schema)) + builder.WriteByte('.') + builder.WriteString(quoteIdentifier(table)) + builder.WriteByte('(') + for i, column := range index.Columns { + if i > 0 { + builder.WriteString(", ") + } + builder.WriteString(quoteIdentifier(column)) + } + builder.WriteByte(')') + if index.IndexType != nil && strings.TrimSpace(*index.IndexType) != "" { + builder.WriteString(" INDEXTYPE IS ") + builder.WriteString(strings.TrimSpace(*index.IndexType)) + } + builder.WriteByte(';') + } + if builder.Len() == 0 { + return ddl + } + return appendDDLStatement(ddl, builder.String()) +} + +func (s *server) tableIndexDDL(schema, table string) (string, error) { + rows, err := s.queryRows( + "SELECT TO_CHAR(DBMS_METADATA.GET_DDL('INDEX', ?, ?)) FROM DUAL", + []any{strings.ToUpper(strings.TrimSpace(table)), schema}, + ) + if err != nil { + return "", err + } + defer rows.Close() + var ddl string + if rows.Next() { + if err := rows.Scan(&ddl); err != nil { + return "", err + } + } + if err := rows.Err(); err != nil { + return "", err + } + return ddl, nil +} + +func (s *server) tableComment(schema, table string) (string, error) { + rows, err := s.queryRows(` +SELECT t.COMMENTS +FROM ALL_TABLES t +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(t.TABLE_NAME) = UPPER(?)`, []any{schema, table}) + if err != nil { + return "", err + } + defer rows.Close() + var comment *string + if rows.Next() { + if err := rows.Scan(&comment); err != nil { + return "", err + } + } + if err := rows.Err(); err != nil { + return "", err + } + if comment == nil { + return "", nil + } + return *comment, nil +} + +func appendDDLStatement(ddl, extra string) string { + ddl = strings.TrimRight(ddl, "\r\n\t ") + extra = strings.TrimSpace(extra) + if extra == "" { + return ddl + } + if !strings.HasSuffix(ddl, ";") { + ddl += ";" + } + return ddl + "\n\n" + extra +} + +func columnTypeDDL(column columnInfo) string { + dataType := strings.ToUpper(strings.TrimSpace(column.DataType)) + if column.CharacterMaximumLength != nil { + return fmt.Sprintf("%s(%d)", dataType, *column.CharacterMaximumLength) + } + if column.NumericPrecision != nil && column.NumericScale != nil { + return fmt.Sprintf("%s(%d,%d)", dataType, *column.NumericPrecision, *column.NumericScale) + } + return dataType +} + +func decodeXuguScale(dataType string, scale *int) (*int, *int, *int) { + if scale == nil || *scale < 0 { + return nil, nil, nil + } + upper := strings.ToUpper(dataType) + if strings.Contains(upper, "CHAR") || strings.Contains(upper, "BINARY") { + length := *scale + return nil, nil, &length + } + if strings.Contains(upper, "NUM") || strings.Contains(upper, "DECIMAL") { + precision := *scale / 65536 + numericScale := *scale % 65536 + return &precision, &numericScale, nil + } + return nil, nil, nil +} + +func normalizeXuguColumnType(dataType string, varying any) string { + upper := strings.ToUpper(strings.TrimSpace(dataType)) + if !truthy(varying) { + return dataType + } + switch upper { + case "CHAR": + return "VARCHAR" + case "BINARY": + return "VARBINARY" + default: + return dataType + } +} + +var quotedIdentifierRegexp = regexp.MustCompile(`"([^"]+)"`) + +func parseQuotedIdentifiers(value string) []string { + matches := quotedIdentifierRegexp.FindAllStringSubmatch(value, -1) + result := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) == 2 { + result = append(result, match[1]) + } + } + return result +} + +func parseIndexKeys(value string) []string { + quoted := parseQuotedIdentifiers(value) + if len(quoted) > 0 { + return quoted + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(strings.TrimSpace(part), `"`) + if part != "" { + result = append(result, part) + } + } + return result +} + +func parseForeignKeyColumns(define string) ([]string, []string) { + groups := regexp.MustCompile(`\(([^()]*)\)`).FindAllStringSubmatch(define, -1) + if len(groups) < 2 { + columns := parseQuotedIdentifiers(define) + if len(columns)%2 == 0 { + mid := len(columns) / 2 + return columns[:mid], columns[mid:] + } + return columns, nil + } + return parseQuotedIdentifiers(groups[0][1]), parseQuotedIdentifiers(groups[1][1]) +} + +func truthy(value any) bool { + switch v := normalizeValue(value).(type) { + case bool: + return v + case int64: + return v != 0 + case float64: + return v != 0 + case string: + upper := strings.ToUpper(strings.TrimSpace(v)) + return upper == "T" || upper == "TRUE" || upper == "1" || upper == "Y" || upper == "YES" + default: + return false + } +} + +func stringPtr(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + return &value +} + +func quoteStringLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func indexTypeName(value any) string { + switch fmt.Sprint(normalizeValue(value)) { + case "0": + return "BTREE" + case "1": + return "RTREE" + case "2": + return "FULLTEXT" + case "3": + return "BITMAP" + default: + return fmt.Sprint(normalizeValue(value)) + } +} + +func triggerEventName(value any) string { + switch fmt.Sprint(normalizeValue(value)) { + case "1": + return "INSERT" + case "2": + return "UPDATE" + case "3": + return "INSERT OR UPDATE" + case "4": + return "DELETE" + case "5": + return "INSERT OR DELETE" + case "6": + return "UPDATE OR DELETE" + case "7": + return "INSERT OR UPDATE OR DELETE" + case "8": + return "LOGON" + default: + return fmt.Sprint(normalizeValue(value)) + } +} + +func triggerTimingName(value any) string { + switch fmt.Sprint(normalizeValue(value)) { + case "1": + return "BEFORE" + case "2": + return "INSTEAD" + case "4": + return "AFTER" + default: + return fmt.Sprint(normalizeValue(value)) + } +} + +func joinValues(values []any, sep string) string { + parts := make([]string, len(values)) + for i, value := range values { + if value == nil { + parts[i] = "" + } else { + parts[i] = fmt.Sprint(value) + } + } + return strings.Join(parts, sep) +} + +func decodeParams(params map[string]json.RawMessage, target any) error { + if params == nil { + params = map[string]json.RawMessage{} + } + data, err := json.Marshal(params) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func stringParam(params map[string]json.RawMessage, key string) string { + if params == nil || len(params[key]) == 0 { + return "" + } + var value string + _ = json.Unmarshal(params[key], &value) + return value +} + +func intParam(params map[string]json.RawMessage, key string) int { + if params == nil || len(params[key]) == 0 { + return 0 + } + var value int + _ = json.Unmarshal(params[key], &value) + return value +} + +func stringSliceParam(params map[string]json.RawMessage, key string) []string { + if params == nil || len(params[key]) == 0 { + return nil + } + var values []string + if err := json.Unmarshal(params[key], &values); err == nil { + return values + } + var single string + if err := json.Unmarshal(params[key], &single); err == nil && strings.TrimSpace(single) != "" { + return []string{single} + } + return nil +} + +func errorResponse(id json.RawMessage, err error) response { + return response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: -1, Message: err.Error()}} +} + +func trimStatementSQL(sqlText string) string { + return strings.TrimRight(strings.TrimSpace(sqlText), "; \t\r\n") +} + +func isQuerySQL(sqlText string) bool { + lower := strings.ToLower(strings.TrimSpace(sqlText)) + return strings.HasPrefix(lower, "select") || strings.HasPrefix(lower, "with") +} + +func quoteIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} + +func normalizeValue(value any) any { + switch v := value.(type) { + case nil: + return nil + case []byte: + return string(v) + case time.Time: + return v.Format(time.RFC3339Nano) + case int: + return int64(v) + case int8: + return int64(v) + case int16: + return int64(v) + case int32: + return int64(v) + case int64: + return v + case uint: + return uint64(v) + case uint8: + return uint64(v) + case uint16: + return uint64(v) + case uint32: + return uint64(v) + case uint64: + return v + case float32: + return float64(v) + case float64, bool, string: + return v + case fmt.Stringer: + return v.String() + default: + return fmt.Sprint(v) + } +} + +func emptyIfNil[T any](values []T) []T { + if values == nil { + return []T{} + } + return values +} diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go new file mode 100644 index 0000000..89f3f41 --- /dev/null +++ b/agents/drivers/xugu/main_test.go @@ -0,0 +1,529 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "strings" + "testing" +) + +func TestHandshakeResponse(t *testing.T) { + s := newServer() + resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":7,"method":"handshake","params":{"appVersion":"dev"}}`) + if shutdown { + t.Fatal("handshake should not shut down the server") + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + data, err := json.Marshal(resp.Result) + if err != nil { + t.Fatal(err) + } + var result struct { + ProtocolVersion int `json:"protocolVersion"` + AgentProtocolVersion int `json:"agentProtocolVersion"` + Capabilities []string `json:"capabilities"` + } + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + if result.ProtocolVersion != 1 || result.AgentProtocolVersion != 1 { + t.Fatalf("unexpected protocol versions: %+v", result) + } + contract := protocolContract(t) + if result.ProtocolVersion != contract.ProtocolVersion || result.AgentProtocolVersion != contract.ProtocolVersion { + t.Fatalf("handshake protocol versions do not match contract: result=%+v contract=%+v", result, contract) + } + for _, capability := range result.Capabilities { + if !contains(contract.AllCapabilities, capability) { + t.Fatalf("handshake returned capability %q outside protocol contract %v", capability, contract.AllCapabilities) + } + } + if !contains(result.Capabilities, "query") || !contains(result.Capabilities, "metadata") { + t.Fatalf("expected query and metadata capabilities, got %v", result.Capabilities) + } +} + +func TestCloseMissingQuerySessionReturnsFalse(t *testing.T) { + s := newServer() + resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":8,"method":"close_query_session","params":{"sessionId":"missing"}}`) + if shutdown { + t.Fatal("close_query_session should not shut down the server") + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + if resp.Result != false { + t.Fatalf("expected false result, got %#v", resp.Result) + } +} + +func TestListDataTypesReturnsXuguTypes(t *testing.T) { + s := newServer() + resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":9,"method":"list_data_types","params":{"database":"demo"}}`) + if shutdown { + t.Fatal("list_data_types should not shut down the server") + } + if resp.Error != nil { + t.Fatalf("unexpected error: %v", resp.Error) + } + data, err := json.Marshal(resp.Result) + if err != nil { + t.Fatal(err) + } + var result []string + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + for _, want := range []string{"INTEGER", "VARCHAR", "NUMERIC", "INT"} { + if !contains(result, want) { + t.Fatalf("expected data type %q in %v", want, result) + } + } +} + +func TestEmptyResultSlicesMarshalAsArrays(t *testing.T) { + data, err := json.Marshal(queryResult{}) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"column_types":null`) || strings.Contains(text, `"rows":null`) { + t.Fatalf("query result should marshal nil slices as arrays: %s", text) + } + + data, err = json.Marshal(indexInfo{}) + if err != nil { + t.Fatal(err) + } + text = string(data) + if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"included_columns":null`) { + t.Fatalf("index info should marshal nil slices as arrays: %s", text) + } +} + +func TestGetTableDDLResultMarshalsAsString(t *testing.T) { + data, err := json.Marshal("CREATE TABLE SYSDBA.ORDERS (ID INT)") + if err != nil { + t.Fatal(err) + } + var ddl string + if err := json.Unmarshal(data, &ddl); err != nil { + t.Fatalf("get_table_ddl result must deserialize as a string: %v", err) + } +} + +func TestBuildDSNUsesConnectionStringWhenProvided(t *testing.T) { + dsn := buildDSN(connectParams{ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138"}) + + if dsn != "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138" { + t.Fatalf("unexpected dsn: %s", dsn) + } +} + +func protocolContract(t *testing.T) struct { + ProtocolVersion int `json:"protocolVersion"` + AllCapabilities []string `json:"allCapabilities"` +} { + t.Helper() + data, err := os.ReadFile("../../common/src/main/resources/agent-protocol-v1.json") + if err != nil { + t.Fatal(err) + } + var contract struct { + ProtocolVersion int `json:"protocolVersion"` + AllCapabilities []string `json:"allCapabilities"` + } + if err := json.Unmarshal(data, &contract); err != nil { + t.Fatal(err) + } + return contract +} + +func TestBuildDSNUsesConnectionFields(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Port: 15138, + Database: "demo", + Username: "sysdba", + Password: "secret", + }) + + for _, part := range []string{"IP=db.example.com", "DB=demo", "User=sysdba", "PWD=secret", "Port=15138"} { + if !strings.Contains(dsn, part) { + t.Fatalf("dsn should contain %s, got: %s", part, dsn) + } + } +} + +func TestBuildDSNUsesDefaultPort(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Database: "demo", + Username: "sysdba", + Password: "secret", + }) + + if !strings.Contains(dsn, "Port=5138") { + t.Fatalf("dsn should default to Xugu port, got: %s", dsn) + } +} + +func TestBuildDSNParsesJdbcURL(t *testing.T) { + dsn := buildDSN(connectParams{ + Username: "sysdba", + Password: "secret", + ConnectionString: "jdbc:xugu://db.example.com:15138/demo", + }) + + for _, part := range []string{"IP=db.example.com", "DB=demo", "User=sysdba", "PWD=secret", "Port=15138"} { + if !strings.Contains(dsn, part) { + t.Fatalf("dsn should contain %s, got: %s", part, dsn) + } + } +} + +func TestBuildDSNParsesDBXURL(t *testing.T) { + dsn := buildDSN(connectParams{ + ConnectionString: "xugu://sysdba:secret@db.example.com:15138/demo", + }) + + for _, part := range []string{"IP=db.example.com", "DB=demo", "User=sysdba", "PWD=secret", "Port=15138"} { + if !strings.Contains(dsn, part) { + t.Fatalf("dsn should contain %s, got: %s", part, dsn) + } + } +} + +func TestBuildDSNAppendsURLParams(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Database: "demo", + Username: "sysdba", + Password: "secret", + URLParams: "AUTO_COMMIT=on;CHAR_SET=UTF8", + }) + + for _, part := range []string{"AUTO_COMMIT=on", "CHAR_SET=UTF8"} { + if !strings.Contains(dsn, part) { + t.Fatalf("dsn should contain %s, got: %s", part, dsn) + } + } +} + +func TestBuildDSNDefaultsToUTF8(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Database: "demo", + Username: "sysdba", + Password: "secret", + }) + + if !strings.Contains(dsn, "CHAR_SET=UTF8") { + t.Fatalf("dsn should default to UTF8, got: %s", dsn) + } +} + +func TestBuildDSNRespectsExplicitCharset(t *testing.T) { + dsn := buildDSN(connectParams{ + Host: "db.example.com", + Database: "demo", + Username: "sysdba", + Password: "secret", + URLParams: "CHAR_SET=GBK", + }) + + if strings.Contains(dsn, "CHAR_SET=UTF8") || !strings.Contains(dsn, "CHAR_SET=GBK") { + t.Fatalf("dsn should respect explicit charset, got: %s", dsn) + } +} + +func TestListDatabasesSQLUsesXuguDictionary(t *testing.T) { + sqlText := strings.ToUpper(xuguListDatabasesSQL) + + if !strings.Contains(sqlText, "ALL_DATABASES") || strings.Contains(sqlText, "SYS_DATABASES") { + t.Fatalf("database listing should query low-privilege ALL_DATABASES, got: %s", xuguListDatabasesSQL) + } +} + +func TestFallbackDatabasesFromParams(t *testing.T) { + cases := []struct { + name string + params connectParams + want string + }{ + { + name: "database field", + params: connectParams{ + Database: "LOWPRIV", + }, + want: "LOWPRIV", + }, + { + name: "dbx url", + params: connectParams{ + ConnectionString: "xugu://user:secret@db.example.com:5138/demo", + }, + want: "demo", + }, + { + name: "jdbc url", + params: connectParams{ + ConnectionString: "jdbc:xugu://db.example.com:5138/reporting", + }, + want: "reporting", + }, + { + name: "native dsn", + params: connectParams{ + ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138", + }, + want: "SYSTEM", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := fallbackDatabasesFromParams(tc.params) + if len(got) != 1 || got[0].Name != tc.want { + t.Fatalf("unexpected fallback databases: got=%v want=%s", got, tc.want) + } + }) + } +} + +func TestUseDatabaseSkipsConfiguredDatabase(t *testing.T) { + s := newServer() + s.params = connectParams{Database: "SYSTEM"} + + if err := s.useDatabase("system"); err != nil { + t.Fatalf("expected configured database USE to be skipped, got: %v", err) + } +} + +func TestConfiguredDatabaseName(t *testing.T) { + cases := []struct { + params connectParams + want string + }{ + {params: connectParams{Database: "SYSTEM"}, want: "SYSTEM"}, + {params: connectParams{ConnectionString: "xugu://user:secret@db.example.com:5138/demo"}, want: "demo"}, + {params: connectParams{ConnectionString: "jdbc:xugu://db.example.com:5138/reporting"}, want: "reporting"}, + {params: connectParams{ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret"}, want: "SYSTEM"}, + } + + for _, tc := range cases { + if got := configuredDatabaseName(tc.params); got != tc.want { + t.Fatalf("configuredDatabaseName(%+v) = %q, want %q", tc.params, got, tc.want) + } + } +} + +func TestSchemaListingSQLUsesLowPrivilegeDictionary(t *testing.T) { + sqlText := strings.ToUpper(xuguListSchemasSQL) + + if !strings.Contains(sqlText, "ALL_SCHEMAS") || strings.Contains(sqlText, "SYS_SCHEMAS") { + t.Fatalf("schema listing should query low-privilege ALL_SCHEMAS, got: %s", xuguListSchemasSQL) + } +} + +func TestPrimaryKeySQLUsesLowPrivilegeDictionary(t *testing.T) { + sqlText := strings.ToUpper(xuguPrimaryKeyColumnsSQL) + + for _, want := range []string{"ALL_CONSTRAINTS", "ALL_TABLES", "ALL_SCHEMAS"} { + if !strings.Contains(sqlText, want) { + t.Fatalf("primary key listing should query %s, got: %s", want, xuguPrimaryKeyColumnsSQL) + } + } + for _, forbidden := range []string{"SYS_CONSTRAINTS", "SYS_TABLES", "SYS_SCHEMAS"} { + if strings.Contains(sqlText, forbidden) { + t.Fatalf("primary key listing should not query %s, got: %s", forbidden, xuguPrimaryKeyColumnsSQL) + } + } +} + +func TestColumnSQLUsesLowPrivilegeDictionary(t *testing.T) { + sqlText := strings.ToUpper(xuguListColumnsSQL) + + for _, want := range []string{"ALL_COLUMNS", "ALL_TABLES", "ALL_SCHEMAS", "COMMENTS", `"VARYING"`} { + if !strings.Contains(sqlText, want) { + t.Fatalf("column listing should query %s, got: %s", want, xuguListColumnsSQL) + } + } + for _, forbidden := range []string{"SYS_COLUMNS", "SYS_TABLES", "SYS_SCHEMAS"} { + if strings.Contains(sqlText, forbidden) { + t.Fatalf("column listing should not query %s, got: %s", forbidden, xuguListColumnsSQL) + } + } +} + +func TestIndexSQLUsesLowPrivilegeDictionary(t *testing.T) { + sqlText := strings.ToUpper(xuguListIndexesSQL) + + for _, want := range []string{"ALL_INDEXES", "ALL_TABLES", "ALL_SCHEMAS", "KEYS"} { + if !strings.Contains(sqlText, want) { + t.Fatalf("index listing should query %s, got: %s", want, xuguListIndexesSQL) + } + } + for _, forbidden := range []string{"SYS_INDEXES", "SYS_TABLES", "SYS_SCHEMAS"} { + if strings.Contains(sqlText, forbidden) { + t.Fatalf("index listing should not query %s, got: %s", forbidden, xuguListIndexesSQL) + } + } +} + +func TestXuguMetadataAccessErrorDetection(t *testing.T) { + if !isXuguMetadataAccessError(errors.New("[E18012] 权限不够")) { + t.Fatal("expected E18012 permission error to be treated as metadata access error") + } + if isXuguMetadataAccessError(errors.New("network timeout")) { + t.Fatal("network errors should not trigger database-list fallback") + } +} + +func TestXuguListTablesQueryAppliesMetadataConstraints(t *testing.T) { + query := xuguListTablesQuery("APP", metadataListConstraints{ + Filter: "ord_", + ObjectTypes: []string{"view", "table", "VIEW"}, + Limit: 25, + Offset: 50, + }) + + for _, want := range []string{ + "UPPER(TABLE_NAME) LIKE ? ESCAPE '\\'", + "TABLE_TYPE IN (?,?)", + "ORDER BY TABLE_TYPE, TABLE_NAME", + "ROWNUM <= ?", + "DBX_RN > ?", + } { + if !strings.Contains(query.SQL, want) { + t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL) + } + } + + wantArgs := []any{"APP", "APP", `%O%R%D%\_%`, "TABLE", "VIEW", 75, 50} + assertArgs(t, query.Args, wantArgs) +} + +func TestXuguListObjectsQueryRejectsUnsupportedObjectTypes(t *testing.T) { + query := xuguListObjectsQuery("APP", metadataListConstraints{ + ObjectTypes: []string{"INDEX"}, + Limit: 10, + }) + + if !strings.Contains(query.SQL, "1 = 0") { + t.Fatalf("unsupported object type should produce empty-result predicate:\n%s", query.SQL) + } + + wantArgs := []any{"APP", "APP", 10, 0} + assertArgs(t, query.Args, wantArgs) +} + +func TestMetadataListConstraintsFromParams(t *testing.T) { + params := map[string]json.RawMessage{ + "filter": json.RawMessage(`"tab"`), + "limit": json.RawMessage(`30`), + "offset": json.RawMessage(`5`), + "object_types": json.RawMessage(`["TABLE","VIEW"]`), + } + + constraints := metadataListConstraintsFromParams(params) + if constraints.Filter != "tab" || constraints.Limit != 30 || constraints.Offset != 5 { + t.Fatalf("unexpected constraints: %+v", constraints) + } + if len(constraints.ObjectTypes) != 2 || constraints.ObjectTypes[0] != "TABLE" || constraints.ObjectTypes[1] != "VIEW" { + t.Fatalf("unexpected object types: %+v", constraints.ObjectTypes) + } +} + +func assertArgs(t *testing.T, got []any, want []any) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("args length = %d, want %d: got=%#v want=%#v", len(got), len(want), got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("arg %d = %#v, want %#v; args=%#v", i, got[i], want[i], got) + } + } +} + +func TestParseForeignKeyColumns(t *testing.T) { + local, ref := parseForeignKeyColumns(`("C1","C2")("ID1","ID2")`) + + if strings.Join(local, ",") != "C1,C2" || strings.Join(ref, ",") != "ID1,ID2" { + t.Fatalf("unexpected foreign key columns: local=%v ref=%v", local, ref) + } +} + +func TestDecodeXuguScale(t *testing.T) { + numericScale := 32*65536 + 6 + precision, scale, length := decodeXuguScale("NUMERIC", &numericScale) + if precision == nil || *precision != 32 || scale == nil || *scale != 6 || length != nil { + t.Fatalf("unexpected numeric scale decode: precision=%v scale=%v length=%v", precision, scale, length) + } + + charScale := 128 + precision, scale, length = decodeXuguScale("VARCHAR", &charScale) + if precision != nil || scale != nil || length == nil || *length != 128 { + t.Fatalf("unexpected char scale decode: precision=%v scale=%v length=%v", precision, scale, length) + } +} + +func TestNormalizeXuguColumnTypeUsesVaryingFlag(t *testing.T) { + tests := []struct { + name string + dataType string + varying any + want string + }{ + {name: "varying char", dataType: "CHAR", varying: true, want: "VARCHAR"}, + {name: "fixed char", dataType: "CHAR", varying: false, want: "CHAR"}, + {name: "varying binary", dataType: "BINARY", varying: true, want: "VARBINARY"}, + {name: "fixed binary", dataType: "BINARY", varying: false, want: "BINARY"}, + {name: "other varying type", dataType: "NUMERIC", varying: true, want: "NUMERIC"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeXuguColumnType(tt.dataType, tt.varying); got != tt.want { + t.Fatalf("normalizeXuguColumnType(%q, %v) = %q, want %q", tt.dataType, tt.varying, got, tt.want) + } + }) + } +} + +func TestAppendDDLStatement(t *testing.T) { + got := appendDDLStatement("CREATE TABLE \"T\" (\"ID\" INT)\n", "CREATE INDEX \"IDX\" ON \"T\"(\"ID\");") + want := "CREATE TABLE \"T\" (\"ID\" INT);\n\nCREATE INDEX \"IDX\" ON \"T\"(\"ID\");" + + if got != want { + t.Fatalf("unexpected DDL append:\ngot: %q\nwant: %q", got, want) + } +} + +func TestQuoteStringLiteralEscapesSingleQuotes(t *testing.T) { + if got := quoteStringLiteral("owner's note"); got != "'owner''s note'" { + t.Fatalf("unexpected quoted string: %s", got) + } +} + +func TestNormalizeValuePreservesDriverNumericTypes(t *testing.T) { + if value := normalizeValue(int32(7)); value != int64(7) { + t.Fatalf("expected int32 to normalize to int64, got %#v", value) + } + if value := normalizeValue(float32(1.25)); value != float64(float32(1.25)) { + t.Fatalf("expected float32 to normalize to float64, got %#v", value) + } +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/agents/drivers/yashandb/build.gradle b/agents/drivers/yashandb/build.gradle new file mode 100644 index 0000000..5a80412 --- /dev/null +++ b/agents/drivers/yashandb/build.gradle @@ -0,0 +1,9 @@ +dependencies { + implementation 'com.yashandb:yashandb-jdbc:1.9.27' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': '崖山 YashanDB', 'Main-Class': 'com.dbx.agent.yashandb.YashandbAgent') + } +} diff --git a/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java b/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java new file mode 100644 index 0000000..cf11cc0 --- /dev/null +++ b/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java @@ -0,0 +1,22 @@ +package com.dbx.agent.yashandb; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +public final class YashandbAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile YASHANDB_PROFILE = new JdbcAgentProfile( + "com.yashandb.jdbc.Driver", + "jdbc:yasdb://{host}:{port}/{database}", + 1688, + true + ); + + public YashandbAgent() { + super(YASHANDB_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new YashandbAgent()).run(); + } +} diff --git a/agents/drivers/yashandb/src/test/java/com/dbx/agent/yashandb/YashandbAgentTest.java b/agents/drivers/yashandb/src/test/java/com/dbx/agent/yashandb/YashandbAgentTest.java new file mode 100644 index 0000000..0ae5c64 --- /dev/null +++ b/agents/drivers/yashandb/src/test/java/com/dbx/agent/yashandb/YashandbAgentTest.java @@ -0,0 +1,30 @@ +package com.dbx.agent.yashandb; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest; +import java.util.Collections; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class YashandbAgentTest extends JdbcFakeExecutionBehaviorTest { + @Override + protected DatabaseAgent createAgent() { + return new YashandbAgent(); + } + + @Override + protected String resultSetSql() { + return "SELECT 1 FROM DUAL"; + } + + @Test + void declaresYashandbJdbcProfile() { + JdbcAgentProfile profile = YashandbAgent.YASHANDB_PROFILE; + + Assertions.assertEquals("com.yashandb.jdbc.Driver", profile.getDriverClass()); + Assertions.assertEquals("jdbc:yasdb://{host}:{port}/{database}", profile.getUrlTemplate()); + Assertions.assertTrue(profile.getSkipExecutionContext()); + Assertions.assertEquals(Collections.emptySet(), profile.getExcludedSchemas()); + } +} diff --git a/agents/drivers/zookeeper/build.gradle b/agents/drivers/zookeeper/build.gradle new file mode 100644 index 0000000..f679c11 --- /dev/null +++ b/agents/drivers/zookeeper/build.gradle @@ -0,0 +1,13 @@ +dependencies { + implementation 'com.google.code.gson:gson:2.12.1' + implementation 'org.apache.curator:curator-framework:5.9.0' + runtimeOnly 'org.slf4j:slf4j-simple:1.7.36' + testImplementation 'org.apache.curator:curator-test:5.9.0' +} + +tasks.named('shadowJar') { + mergeServiceFiles() + manifest { + attributes('Agent-Label': 'Apache ZooKeeper', 'Main-Class': 'com.dbx.agent.zookeeper.ZooKeeperAgent') + } +} diff --git a/agents/drivers/zookeeper/src/main/java/com/dbx/agent/zookeeper/ZooKeeperAgent.java b/agents/drivers/zookeeper/src/main/java/com/dbx/agent/zookeeper/ZooKeeperAgent.java new file mode 100644 index 0000000..72b4329 --- /dev/null +++ b/agents/drivers/zookeeper/src/main/java/com/dbx/agent/zookeeper/ZooKeeperAgent.java @@ -0,0 +1,664 @@ +package com.dbx.agent.zookeeper; + +import com.dbx.agent.AgentProtocol; +import com.google.gson.*; +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.retry.ExponentialBackoffRetry; +import org.apache.zookeeper.CreateMode; +import org.apache.zookeeper.KeeperException; +import org.apache.zookeeper.data.Stat; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class ZooKeeperAgent { + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private static final int DEFAULT_LIMIT = 100; + private static final int DEFAULT_SESSION_TIMEOUT_MS = 30000; + private static final int DEFAULT_CONNECTION_TIMEOUT_MS = 15000; + private static final int DEFAULT_BASE_SLEEP_TIME_MS = 1000; + private static final int DEFAULT_MAX_RETRIES = 3; + private static final String STAT_LOOKUP_CONCURRENCY_PROPERTY = "dbx.zookeeper.statLookupConcurrency"; + private static final String STAT_LOOKUP_CONCURRENCY_ENV = "DBX_ZOOKEEPER_STAT_LOOKUP_CONCURRENCY"; + private static final int DEFAULT_STAT_LOOKUP_CONCURRENCY = 16; + private static final int MIN_STAT_LOOKUP_CONCURRENCY = 1; + private static final int MAX_STAT_LOOKUP_CONCURRENCY = 64; + private static final int STAT_LOOKUP_CONCURRENCY = configuredStatLookupConcurrency(); + private static final ExecutorService STAT_LOOKUP_EXECUTOR = Executors.newFixedThreadPool( + STAT_LOOKUP_CONCURRENCY, + daemonThreadFactory("dbx-zookeeper-stat-lookup-") + ); + private static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( + AgentProtocol.CAPABILITY_CONNECT, + AgentProtocol.CAPABILITY_TEST_CONNECTION, + AgentProtocol.CAPABILITY_KV + )); + private static CuratorFramework client; + + private ZooKeeperAgent() { + } + + private static Object handshakeResult() { + return new HandshakeResult(AgentProtocol.PROTOCOL_VERSION, AgentProtocol.PROTOCOL_VERSION, CAPABILITIES); + } + + private static Object connect(JsonObject params) throws Exception { + JsonObject connection = connectionObject(params); + CuratorFramework nextClient = buildClient(connection); + try { + startAndVerify(nextClient, connection); + closeClient(); + client = nextClient; + return Collections.singletonMap("ok", true); + } catch (Exception e) { + nextClient.close(); + throw e; + } + } + + private static Object testConnection(JsonObject params) throws Exception { + JsonObject connection = connectionObject(params); + CuratorFramework probe = buildClient(connection); + try { + startAndVerify(probe, connection); + return Collections.singletonMap("ok", true); + } finally { + probe.close(); + } + } + + static CuratorFramework buildClient(JsonObject connection) { + if (hasTlsOptions(connection)) { + throw new IllegalArgumentException("ZooKeeper TLS is not supported"); + } + + int baseSleepTimeMs = intOrDefault(connection, "base_sleep_time_ms", DEFAULT_BASE_SLEEP_TIME_MS); + int maxRetries = intOrDefault(connection, "max_retries", DEFAULT_MAX_RETRIES); + CuratorFrameworkFactory.Builder builder = CuratorFrameworkFactory.builder() + .connectString(connectString(connection)) + .sessionTimeoutMs(intOrDefault(connection, "session_timeout_ms", DEFAULT_SESSION_TIMEOUT_MS)) + .connectionTimeoutMs(intOrDefault(connection, "connection_timeout_ms", DEFAULT_CONNECTION_TIMEOUT_MS)) + .retryPolicy(new ExponentialBackoffRetry(baseSleepTimeMs, maxRetries)); + + String namespace = trimSlashes(stringOrEmpty(connection, "namespace")); + if (!namespace.isBlank()) { + builder.namespace(namespace); + } + + String username = stringOrEmpty(connection, "username"); + String password = stringOrEmpty(connection, "password"); + if (!username.isBlank()) { + builder.authorization("digest", (username + ":" + password).getBytes(StandardCharsets.UTF_8)); + } + + return builder.build(); + } + + static String connectString(JsonObject connection) { + String configured = firstNonBlank( + stringOrNull(connection, "zookeeper_connect_string"), + stringOrNull(connection, "connect_string"), + stringOrNull(connection, "connection_string") + ); + if (configured != null) { + return configured; + } + return stringOrDefault(connection, "host", "127.0.0.1") + ":" + + intOrDefault(connection, "port", 2181); + } + + private static void startAndVerify(CuratorFramework active, JsonObject connection) throws Exception { + active.start(); + int timeoutMs = intOrDefault(connection, "connection_timeout_ms", DEFAULT_CONNECTION_TIMEOUT_MS); + if (!active.blockUntilConnected(timeoutMs, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("Connection timed out"); + } + if (active.checkExists().forPath("/") == null) { + throw new IllegalStateException("Root znode is not readable"); + } + } + + private static Object listPrefix(JsonObject params) throws Exception { + CuratorFramework active = requireClient(); + String root = normalizePath(stringOrDefault(params, "prefix", "/")); + boolean recursive = boolOrDefault(params, "recursive", true); + int limit = Math.max(1, intOrDefault(params, "limit", DEFAULT_LIMIT)); + Cursor cursor = null; + String continuation = stringOrNull(params, "continuation"); + if (continuation != null && !continuation.isBlank()) { + cursor = decodeCursor(continuation); + if (!root.equals(cursor.root) || recursive != cursor.recursive) { + throw new IllegalArgumentException("Continuation does not match request"); + } + } + + Map result = new LinkedHashMap<>(); + List> keys = new ArrayList<>(); + Stat rootStat = active.checkExists().forPath(root); + if (rootStat == null) { + result.put("keys", keys); + result.put("continuation", null); + return result; + } + + List paths = recursive ? listRecursive(active, root) : listDirectChildren(active, root); + Collections.sort(paths); + int offset = cursor == null ? 0 : Math.max(0, cursor.offset); + int end = Math.min(paths.size(), offset + limit); + keys.addAll(listRowsWithMetadata(active, paths.subList(offset, end))); + + result.put("keys", keys); + result.put("continuation", end < paths.size() ? encodeCursor(new Cursor(root, recursive, end)) : null); + return result; + } + + private static Object get(JsonObject params) throws Exception { + CuratorFramework active = requireClient(); + String path = normalizePath(stringOrDefault(params, "key", "")); + Map result = new LinkedHashMap<>(); + Stat exists = active.checkExists().forPath(path); + if (exists == null) { + result.put("found", false); + result.put("key", path); + result.put("value", null); + result.put("metadata", null); + return result; + } + + Stat stat = new Stat(); + byte[] bytes = active.getData().storingStatIn(stat).forPath(path); + result.put("found", true); + result.put("key", path); + result.put("value", valueObject(bytes)); + result.put("metadata", statMetadata(stat)); + return result; + } + + private static Object put(JsonObject params) throws Exception { + CuratorFramework active = requireClient(); + String path = normalizePath(stringOrDefault(params, "key", "")); + requireMutablePath(path, "modified"); + byte[] bytes = parseValue(params.getAsJsonObject("value")); + + String writeMode = stringOrDefault(params, "writeMode", "upsert"); + return switch (writeMode) { + case "create" -> create(active, path, bytes, createMode(params)); + case "update" -> update(active, path, bytes); + case "upsert" -> upsert(active, path, bytes); + default -> throw new IllegalArgumentException("Unsupported writeMode: " + writeMode); + }; + } + + private static Object upsert(CuratorFramework active, String path, byte[] bytes) throws Exception { + Stat stat = active.checkExists().forPath(path); + if (stat == null) { + active.create().creatingParentsIfNeeded().forPath(path, bytes); + stat = active.checkExists().forPath(path); + } else { + stat = active.setData().forPath(path, bytes); + } + return putResult(stat); + } + + private static Object create(CuratorFramework active, String path, byte[] bytes, CreateMode createMode) throws Exception { + String createdPath = active.create() + .creatingParentsIfNeeded() + .withMode(createMode) + .forPath(path, bytes); + Stat stat = active.checkExists().forPath(createdPath); + Map result = putResult(stat); + result.put("key", createdPath); + result.put("createdKey", createdPath); + return result; + } + + private static Object update(CuratorFramework active, String path, byte[] bytes) throws Exception { + return putResult(active.setData().forPath(path, bytes)); + } + + private static Map putResult(Stat stat) { + Map result = new LinkedHashMap<>(); + result.put("version", stat.getVersion()); + result.put("mtime", stat.getMtime()); + return result; + } + + private static CreateMode createMode(JsonObject params) { + String createMode = stringOrDefault(params, "createMode", "persistent"); + return switch (createMode) { + case "persistent" -> CreateMode.PERSISTENT; + case "ephemeral" -> CreateMode.EPHEMERAL; + case "persistent_sequential" -> CreateMode.PERSISTENT_SEQUENTIAL; + case "ephemeral_sequential" -> CreateMode.EPHEMERAL_SEQUENTIAL; + default -> throw new IllegalArgumentException("Unsupported createMode: " + createMode); + }; + } + + private static Object delete(JsonObject params) throws Exception { + CuratorFramework active = requireClient(); + String path = normalizePath(stringOrDefault(params, "key", "")); + requireMutablePath(path, "deleted"); + boolean recursive = boolOrDefault(params, "recursive", false); + int deleted; + try { + if (active.checkExists().forPath(path) == null) { + deleted = 0; + } else if (recursive) { + deleted = countSubtree(active, path); + active.delete().deletingChildrenIfNeeded().forPath(path); + } else { + active.delete().forPath(path); + deleted = 1; + } + } catch (KeeperException.NoNodeException e) { + deleted = 0; + } + return Collections.singletonMap("deleted", deleted); + } + + private static Object dispatch(String method, JsonObject params) throws Exception { + return switch (method) { + case AgentProtocol.METHOD_HANDSHAKE -> handshakeResult(); + case AgentProtocol.METHOD_CONNECT -> connect(params); + case AgentProtocol.METHOD_TEST_CONNECTION -> testConnection(params); + case AgentProtocol.KV_METHOD_LIST_PREFIX -> listPrefix(params); + case AgentProtocol.KV_METHOD_GET -> get(params); + case AgentProtocol.KV_METHOD_PUT -> put(params); + case AgentProtocol.KV_METHOD_DELETE -> delete(params); + case AgentProtocol.METHOD_DISCONNECT -> { + closeClient(); + yield Collections.singletonMap("ok", true); + } + case AgentProtocol.METHOD_SHUTDOWN -> { + closeClient(); + STAT_LOOKUP_EXECUTOR.shutdownNow(); + System.exit(0); + yield Collections.singletonMap("ok", true); + } + default -> throw new IllegalArgumentException("Unknown method: " + method); + }; + } + + static String handleRequest(String line) { + JsonObject req = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = req.get("id"); + String method = req.get("method").getAsString(); + JsonObject params = req.has("params") && req.get("params").isJsonObject() + ? req.getAsJsonObject("params") + : new JsonObject(); + + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + response.add("id", id); + + try { + Object result = dispatch(method, params); + response.add("result", GSON.toJsonTree(result)); + } catch (Exception e) { + JsonObject error = new JsonObject(); + error.addProperty("code", -1); + error.addProperty("message", e.getMessage() == null ? "Unknown error" : e.getMessage()); + response.add("error", error); + } + + return GSON.toJson(response); + } + + private static JsonObject connectionObject(JsonObject params) { + JsonElement connection = params.get("connection"); + return connection != null && connection.isJsonObject() ? connection.getAsJsonObject() : params; + } + + private static CuratorFramework requireClient() { + if (client == null) { + throw new IllegalStateException("Not connected"); + } + return client; + } + + private static void closeClient() { + if (client != null) { + client.close(); + client = null; + } + } + + static String normalizePath(String path) { + String normalized = path == null ? "" : path; + if (normalized.isEmpty() || "/".equals(normalized)) { + return "/"; + } + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static String childPath(String parent, String child) { + return "/".equals(parent) ? "/" + child : parent + "/" + child; + } + + private static void requireMutablePath(String path, String action) { + if ("/".equals(path)) { + throw new IllegalArgumentException("Root znode cannot be " + action); + } + } + + private static Map statMetadata(Stat stat) { + Map metadata = new LinkedHashMap<>(); + long czxid = stat.getCzxid(); + long mzxid = stat.getMzxid(); + long pzxid = stat.getPzxid(); + int dataLength = stat.getDataLength(); + metadata.put("czxid", czxid); + metadata.put("mzxid", mzxid); + metadata.put("pzxid", pzxid); + metadata.put("ctime", stat.getCtime()); + metadata.put("mtime", stat.getMtime()); + metadata.put("version", stat.getVersion()); + metadata.put("cversion", stat.getCversion()); + metadata.put("aversion", stat.getAversion()); + metadata.put("ephemeralOwner", stat.getEphemeralOwner()); + metadata.put("dataLength", dataLength); + metadata.put("numChildren", stat.getNumChildren()); + metadata.put("createRevision", czxid); + metadata.put("modRevision", mzxid); + metadata.put("valueSize", dataLength); + return metadata; + } + + private static Map valueObject(byte[] bytes) { + Map value = new LinkedHashMap<>(); + String utf8 = strictUtf8(bytes); + if (utf8 != null) { + value.put("encoding", "utf8"); + value.put("data", utf8); + } else { + value.put("encoding", "base64"); + value.put("data", Base64.getEncoder().encodeToString(bytes)); + } + return value; + } + + private static byte[] parseValue(JsonObject value) { + if (value == null) { + return new byte[0]; + } + String encoding = stringOrDefault(value, "encoding", "utf8"); + String data = stringOrDefault(value, "data", ""); + if ("base64".equals(encoding)) { + return Base64.getDecoder().decode(data); + } + if (!"utf8".equals(encoding)) { + throw new IllegalArgumentException("Unsupported value encoding: " + encoding); + } + return data.getBytes(StandardCharsets.UTF_8); + } + + private static String strictUtf8(byte[] bytes) { + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return decoder.decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + return null; + } + } + + private static String encodeCursor(Cursor cursor) { + JsonObject object = new JsonObject(); + object.addProperty("root", cursor.root); + object.addProperty("recursive", cursor.recursive); + object.addProperty("offset", cursor.offset); + return Base64.getEncoder().encodeToString(GSON.toJson(object).getBytes(StandardCharsets.UTF_8)); + } + + private static Cursor decodeCursor(String continuation) { + String json = new String(Base64.getDecoder().decode(continuation), StandardCharsets.UTF_8); + JsonObject object = JsonParser.parseString(json).getAsJsonObject(); + return new Cursor( + object.get("root").getAsString(), + object.get("recursive").getAsBoolean(), + object.get("offset").getAsInt() + ); + } + + private static List listDirectChildren(CuratorFramework active, String root) throws Exception { + List paths = new ArrayList<>(); + List children = new ArrayList<>(active.getChildren().forPath(root)); + Collections.sort(children); + for (String child : children) { + paths.add(childPath(root, child)); + } + return paths; + } + + private static List listRecursive(CuratorFramework active, String root) throws Exception { + List result = new ArrayList<>(); + collectRecursive(active, root, result); + return result; + } + + private static List> listRowsWithMetadata(CuratorFramework active, List paths) throws Exception { + if (paths.isEmpty()) { + return Collections.emptyList(); + } + + try { + List>> futures = new ArrayList<>(); + for (String path : paths) { + futures.add(STAT_LOOKUP_EXECUTOR.submit(() -> rowWithMetadata(active, path))); + } + + List> rows = new ArrayList<>(); + for (Future> future : futures) { + Map row = future.get(); + if (row != null) { + rows.add(row); + } + } + return rows; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception exception) { + throw exception; + } + if (cause instanceof Error error) { + throw error; + } + throw new RuntimeException(cause); + } + } + + private static Map rowWithMetadata(CuratorFramework active, String path) throws Exception { + try { + Stat stat = active.checkExists().forPath(path); + if (stat == null) { + return null; + } + Map row = new LinkedHashMap<>(); + row.put("key", path); + row.putAll(statMetadata(stat)); + return row; + } catch (KeeperException.NoNodeException e) { + return null; + } + } + + private static void collectRecursive(CuratorFramework active, String path, List result) throws Exception { + List children; + try { + children = new ArrayList<>(active.getChildren().forPath(path)); + } catch (KeeperException.NoNodeException e) { + return; + } + Collections.sort(children); + for (String child : children) { + String childPath = childPath(path, child); + result.add(childPath); + collectRecursive(active, childPath, result); + } + } + + private static int countSubtree(CuratorFramework active, String path) throws Exception { + try { + if (active.checkExists().forPath(path) == null) { + return 0; + } + int count = 1; + for (String child : active.getChildren().forPath(path)) { + count += countSubtree(active, childPath(path, child)); + } + return count; + } catch (KeeperException.NoNodeException e) { + return 0; + } + } + + private static boolean hasTlsOptions(JsonObject connection) { + return boolOrDefault(connection, "ssl", false) + || firstNonBlank( + stringOrNull(connection, "ca_cert_path"), + stringOrNull(connection, "client_cert_path"), + stringOrNull(connection, "client_key_path"), + stringOrNull(connection, "cert_path"), + stringOrNull(connection, "key_path") + ) != null; + } + + private static String trimSlashes(String value) { + String trimmed = value.trim(); + while (trimmed.startsWith("/")) { + trimmed = trimmed.substring(1); + } + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private static String stringOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } + + private static String stringOrEmpty(JsonObject object, String key) { + return stringOrDefault(object, key, ""); + } + + private static String stringOrDefault(JsonObject object, String key, String fallback) { + String value = stringOrNull(object, key); + return value == null ? fallback : value; + } + + private static int intOrDefault(JsonObject object, String key, int fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsInt(); + } + + private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsBoolean(); + } + + static int configuredStatLookupConcurrency(String propertyValue, String envValue) { + String configured = firstNonBlank(propertyValue, envValue); + if (configured == null) { + return DEFAULT_STAT_LOOKUP_CONCURRENCY; + } + try { + int parsed = Integer.parseInt(configured.trim()); + return Math.max(MIN_STAT_LOOKUP_CONCURRENCY, Math.min(MAX_STAT_LOOKUP_CONCURRENCY, parsed)); + } catch (NumberFormatException e) { + return DEFAULT_STAT_LOOKUP_CONCURRENCY; + } + } + + private static int configuredStatLookupConcurrency() { + return configuredStatLookupConcurrency( + System.getProperty(STAT_LOOKUP_CONCURRENCY_PROPERTY), + System.getenv(STAT_LOOKUP_CONCURRENCY_ENV) + ); + } + + private static ThreadFactory daemonThreadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(1); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + public static void main(String[] args) throws Exception { + // Entry point for the standalone DBX agent process. + System.setProperty("org.slf4j.simpleLogger.logFile", "System.err"); + System.out.println("{\"ready\":true}"); + System.out.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + while (true) { + String line = reader.readLine(); + if (line == null) { + break; + } + + System.out.println(handleRequest(line)); + System.out.flush(); + } + } + + private static final class Cursor { + private final String root; + private final boolean recursive; + private final int offset; + + private Cursor(String root, boolean recursive, int offset) { + this.root = root; + this.recursive = recursive; + this.offset = offset; + } + } + + private static final class HandshakeResult { + private final int protocolVersion; + private final int agentProtocolVersion; + private final List capabilities; + + private HandshakeResult(int protocolVersion, int agentProtocolVersion, List capabilities) { + this.protocolVersion = protocolVersion; + this.agentProtocolVersion = agentProtocolVersion; + this.capabilities = capabilities; + } + } +} diff --git a/agents/drivers/zookeeper/src/test/java/com/dbx/agent/zookeeper/ZooKeeperAgentTest.java b/agents/drivers/zookeeper/src/test/java/com/dbx/agent/zookeeper/ZooKeeperAgentTest.java new file mode 100644 index 0000000..c50f351 --- /dev/null +++ b/agents/drivers/zookeeper/src/test/java/com/dbx/agent/zookeeper/ZooKeeperAgentTest.java @@ -0,0 +1,596 @@ +package com.dbx.agent.zookeeper; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.curator.test.TestingServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +final class ZooKeeperAgentTest { + @AfterEach + void disconnect() { + ZooKeeperAgent.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":999,\"method\":\"disconnect\",\"params\":{}}"); + } + + @Test + void handshakeAdvertisesKvCapability() { + String response = ZooKeeperAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}" + ); + + JsonObject result = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result"); + + Assertions.assertEquals(1, result.get("protocolVersion").getAsInt()); + Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv\""))); + Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"connect\""))); + Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"test_connection\""))); + } + + @Test + void kvMethodDispatchReturnsJsonRpcErrorWhenDisconnected() { + String response = ZooKeeperAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"kv_get\",\"params\":{\"key\":\"/app/name\"}}" + ); + + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + + Assertions.assertEquals(-1, error.get("code").getAsInt()); + Assertions.assertEquals("Not connected", error.get("message").getAsString()); + } + + @Test + void unknownMethodReturnsJsonRpcError() { + String response = ZooKeeperAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"missing_method\",\"params\":{}}" + ); + + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + + Assertions.assertEquals(-1, error.get("code").getAsInt()); + Assertions.assertEquals("Unknown method: missing_method", error.get("message").getAsString()); + } + + @Test + void connectStringUsesConfiguredValuesAndHostPortFallback() { + Assertions.assertEquals( + "zk-1:2181,zk-2:2181", + ZooKeeperAgent.connectString( + JsonParser.parseString("{\"connect_string\":\"zk-1:2181,zk-2:2181\"}").getAsJsonObject() + ) + ); + Assertions.assertEquals( + "zk-main:2181", + ZooKeeperAgent.connectString( + JsonParser.parseString("{\"zookeeper_connect_string\":\"zk-main:2181\"}").getAsJsonObject() + ) + ); + Assertions.assertEquals( + "127.0.0.1:2181", + ZooKeeperAgent.connectString(JsonParser.parseString("{}").getAsJsonObject()) + ); + Assertions.assertEquals( + "zk.local:2281", + ZooKeeperAgent.connectString(JsonParser.parseString("{\"host\":\"zk.local\",\"port\":2281}").getAsJsonObject()) + ); + } + + @Test + void buildClientRejectsTlsOptionsUntilTlsIsSupported() { + JsonObject connection = JsonParser.parseString( + "{\"ssl\":true,\"ca_cert_path\":\"ca.pem\",\"client_cert_path\":\"client.pem\",\"client_key_path\":\"client.key\"}" + ).getAsJsonObject(); + + IllegalArgumentException error = Assertions.assertThrows( + IllegalArgumentException.class, + () -> ZooKeeperAgent.buildClient(connection) + ); + + Assertions.assertEquals("ZooKeeper TLS is not supported", error.getMessage()); + } + + @Test + void normalizePathStandardizesPublicPaths() { + Assertions.assertEquals("/", ZooKeeperAgent.normalizePath("")); + Assertions.assertEquals("/", ZooKeeperAgent.normalizePath("/")); + Assertions.assertEquals("/app", ZooKeeperAgent.normalizePath("app")); + Assertions.assertEquals("/app", ZooKeeperAgent.normalizePath("/app/")); + Assertions.assertEquals("/app/name", ZooKeeperAgent.normalizePath("/app/name")); + } + + @Test + void statLookupConcurrencyUsesConservativeDefaultsAndOverrides() { + Assertions.assertEquals(16, ZooKeeperAgent.configuredStatLookupConcurrency(null, null)); + Assertions.assertEquals(20, ZooKeeperAgent.configuredStatLookupConcurrency("20", "12")); + Assertions.assertEquals(12, ZooKeeperAgent.configuredStatLookupConcurrency(null, "12")); + Assertions.assertEquals(1, ZooKeeperAgent.configuredStatLookupConcurrency("0", null)); + Assertions.assertEquals(64, ZooKeeperAgent.configuredStatLookupConcurrency("128", null)); + Assertions.assertEquals(16, ZooKeeperAgent.configuredStatLookupConcurrency("not-a-number", null)); + } + + @Test + void connectAndTestConnectionWorkAgainstTestingServer() throws Exception { + try (TestingServer server = new TestingServer()) { + JsonObject connect = result(request( + 1, + "connect", + "{\"connection\":{\"connect_string\":\"" + server.getConnectString() + "\"}}" + )); + Assertions.assertTrue(connect.get("ok").getAsBoolean()); + + JsonObject test = result(request( + 2, + "test_connection", + "{\"connection\":{\"connect_string\":\"" + server.getConnectString() + "\"}}" + )); + Assertions.assertTrue(test.get("ok").getAsBoolean()); + } + } + + @Test + void testConnectionDoesNotReplaceActiveClient() throws Exception { + try (TestingServer active = new TestingServer(); TestingServer probe = new TestingServer()) { + result(request(1, "connect", "{\"connection\":{\"connect_string\":\"" + active.getConnectString() + "\"}}")); + result(request( + 2, + "kv_put", + "{\"key\":\"/active\",\"value\":{\"encoding\":\"utf8\",\"data\":\"still-here\"}}" + )); + + result(request(3, "test_connection", "{\"connection\":{\"connect_string\":\"" + probe.getConnectString() + "\"}}")); + + JsonObject get = result(request(4, "kv_get", "{\"key\":\"/active\"}")); + Assertions.assertTrue(get.get("found").getAsBoolean()); + Assertions.assertEquals("still-here", get.getAsJsonObject("value").get("data").getAsString()); + } + } + + @Test + void kvPutCreatesMissingParentsAndGetReadsUtf8Value() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject put = result(request( + 2, + "kv_put", + "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}" + )); + Assertions.assertTrue(put.has("version")); + + JsonObject get = result(request(3, "kv_get", "{\"key\":\"/app/name\"}")); + + Assertions.assertTrue(get.get("found").getAsBoolean()); + Assertions.assertEquals("utf8", get.getAsJsonObject("value").get("encoding").getAsString()); + Assertions.assertEquals("dbx", get.getAsJsonObject("value").get("data").getAsString()); + JsonObject metadata = get.getAsJsonObject("metadata"); + Assertions.assertEquals(0, metadata.get("numChildren").getAsInt()); + Assertions.assertEquals(metadata.get("czxid").getAsLong(), metadata.get("createRevision").getAsLong()); + Assertions.assertEquals(metadata.get("mzxid").getAsLong(), metadata.get("modRevision").getAsLong()); + Assertions.assertEquals(metadata.get("dataLength").getAsInt(), metadata.get("valueSize").getAsInt()); + } + } + + @Test + void kvPutUpdatesExistingZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + result(request( + 3, + "kv_put", + "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx-updated\"}}" + )); + + JsonObject value = result(request(4, "kv_get", "{\"key\":\"/app/name\"}")).getAsJsonObject("value"); + Assertions.assertEquals("dbx-updated", value.get("data").getAsString()); + } + } + + @Test + void kvPutCreatePersistentCreatesOnlyMissingZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject created = result(request( + 2, + "kv_put", + "{\"key\":\"/app/created\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}," + + "\"writeMode\":\"create\",\"createMode\":\"persistent\"}" + )); + JsonObject get = result(request(3, "kv_get", "{\"key\":\"/app/created\"}")); + JsonObject duplicate = error(request( + 4, + "kv_put", + "{\"key\":\"/app/created\",\"value\":{\"encoding\":\"utf8\",\"data\":\"again\"}," + + "\"writeMode\":\"create\",\"createMode\":\"persistent\"}" + )); + + Assertions.assertEquals("/app/created", requiredString(created, "key")); + Assertions.assertEquals("/app/created", requiredString(created, "createdKey")); + Assertions.assertEquals("dbx", get.getAsJsonObject("value").get("data").getAsString()); + Assertions.assertTrue(duplicate.get("message").getAsString().contains("NodeExists")); + } + } + + @Test + void kvPutCreateEphemeralCreatesEphemeralZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject created = result(request( + 2, + "kv_put", + "{\"key\":\"/session/node\",\"value\":{\"encoding\":\"utf8\",\"data\":\"temporary\"}," + + "\"writeMode\":\"create\",\"createMode\":\"ephemeral\"}" + )); + JsonObject get = result(request(3, "kv_get", "{\"key\":\"/session/node\"}")); + + Assertions.assertEquals("/session/node", requiredString(created, "createdKey")); + Assertions.assertEquals("temporary", get.getAsJsonObject("value").get("data").getAsString()); + Assertions.assertTrue(get.getAsJsonObject("metadata").get("ephemeralOwner").getAsLong() > 0); + } + } + + @Test + void kvPutCreatePersistentSequentialReturnsGeneratedPath() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject created = result(request( + 2, + "kv_put", + "{\"key\":\"/jobs/job-\",\"value\":{\"encoding\":\"utf8\",\"data\":\"one\"}," + + "\"writeMode\":\"create\",\"createMode\":\"persistent_sequential\"}" + )); + String createdKey = requiredString(created, "createdKey"); + JsonObject get = result(request(3, "kv_get", "{\"key\":\"" + createdKey + "\"}")); + + Assertions.assertEquals(createdKey, requiredString(created, "key")); + Assertions.assertTrue(createdKey.startsWith("/jobs/job-")); + Assertions.assertTrue(createdKey.length() > "/jobs/job-".length()); + Assertions.assertEquals("one", get.getAsJsonObject("value").get("data").getAsString()); + Assertions.assertEquals(0, get.getAsJsonObject("metadata").get("ephemeralOwner").getAsLong()); + } + } + + @Test + void kvPutCreateEphemeralSequentialReturnsGeneratedEphemeralPath() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject created = result(request( + 2, + "kv_put", + "{\"key\":\"/sessions/member-\",\"value\":{\"encoding\":\"utf8\",\"data\":\"online\"}," + + "\"writeMode\":\"create\",\"createMode\":\"ephemeral_sequential\"}" + )); + String createdKey = requiredString(created, "createdKey"); + JsonObject get = result(request(3, "kv_get", "{\"key\":\"" + createdKey + "\"}")); + + Assertions.assertEquals(createdKey, requiredString(created, "key")); + Assertions.assertTrue(createdKey.startsWith("/sessions/member-")); + Assertions.assertEquals("online", get.getAsJsonObject("value").get("data").getAsString()); + Assertions.assertTrue(get.getAsJsonObject("metadata").get("ephemeralOwner").getAsLong() > 0); + } + } + + @Test + void kvPutUpdateOnlyUpdatesExistingZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject missing = error(request( + 2, + "kv_put", + "{\"key\":\"/app/missing\",\"value\":{\"encoding\":\"utf8\",\"data\":\"x\"},\"writeMode\":\"update\"}" + )); + result(request( + 3, + "kv_put", + "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"before\"},\"writeMode\":\"create\"}" + )); + JsonObject updated = result(request( + 4, + "kv_put", + "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"after\"},\"writeMode\":\"update\"}" + )); + JsonObject value = result(request(5, "kv_get", "{\"key\":\"/app/name\"}")).getAsJsonObject("value"); + + Assertions.assertTrue(missing.get("message").getAsString().contains("NoNode")); + Assertions.assertTrue(updated.get("version").getAsInt() > 0); + Assertions.assertEquals("after", value.get("data").getAsString()); + } + } + + @Test + void kvGetReturnsBase64ForBinaryData() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + result(request(2, "kv_put", "{\"key\":\"/bin\",\"value\":{\"encoding\":\"base64\",\"data\":\"/wAB\"}}")); + + JsonObject value = result(request(3, "kv_get", "{\"key\":\"/bin\"}")).getAsJsonObject("value"); + Assertions.assertEquals("base64", value.get("encoding").getAsString()); + Assertions.assertEquals("/wAB", value.get("data").getAsString()); + } + } + + @Test + void kvPutRejectsRootZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject error = error(request( + 2, + "kv_put", + "{\"key\":\"/\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}" + )); + + Assertions.assertEquals("Root znode cannot be modified", error.get("message").getAsString()); + } + } + + @Test + void kvDeleteReturnsZeroForMissingZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject delete = result(request(4, "kv_delete", "{\"key\":\"/missing\"}")); + + Assertions.assertEquals(0, delete.get("deleted").getAsInt()); + } + } + + @Test + void kvDeleteRemovesLeafZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/leaf\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + + JsonObject delete = result(request(3, "kv_delete", "{\"key\":\"/leaf\"}")); + JsonObject get = result(request(4, "kv_get", "{\"key\":\"/leaf\"}")); + + Assertions.assertEquals(1, delete.get("deleted").getAsInt()); + Assertions.assertFalse(get.get("found").getAsBoolean()); + } + } + + @Test + void kvDeleteRejectsNonRecursiveDeleteOfNonEmptyZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + + JsonObject error = error(request(3, "kv_delete", "{\"key\":\"/app\"}")); + + Assertions.assertTrue(error.get("message").getAsString().contains("not empty")); + } + } + + @Test + void kvDeleteRecursiveDeletesFullSubtreeAndCountsZnodes() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + result(request(3, "kv_put", "{\"key\":\"/app/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}")); + + JsonObject delete = result(request(4, "kv_delete", "{\"key\":\"/app\",\"recursive\":true}")); + JsonObject get = result(request(5, "kv_get", "{\"key\":\"/app/name\"}")); + + Assertions.assertEquals(3, delete.get("deleted").getAsInt()); + Assertions.assertFalse(get.get("found").getAsBoolean()); + } + } + + @Test + void kvDeleteRejectsRootZnode() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject error = error(request(2, "kv_delete", "{\"key\":\"/\",\"recursive\":true}")); + + Assertions.assertEquals("Root znode cannot be deleted", error.get("message").getAsString()); + } + } + + @Test + void listPrefixListsDirectChildrenOfRootWhenRecursiveIsFalse() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}")); + + JsonObject list = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\",\"recursive\":false}")); + + Assertions.assertEquals(List.of("/app", "/config", "/zookeeper"), listedKeys(list)); + Assertions.assertTrue(list.get("continuation").isJsonNull()); + } + } + + @Test + void listPrefixKeepsDirectChildMetadataForLazyExpansion() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}")); + + JsonObject list = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\",\"recursive\":false}")); + JsonObject app = listedRow(list, "/app"); + JsonObject config = listedRow(list, "/config"); + + Assertions.assertEquals(1, app.get("numChildren").getAsInt()); + Assertions.assertEquals(0, config.get("numChildren").getAsInt()); + Assertions.assertEquals(3, config.get("dataLength").getAsInt()); + } + } + + @Test + void listPrefixDefaultsToRecursiveForDbxKvBrowser() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}")); + + JsonObject list = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\"}")); + + Assertions.assertEquals( + List.of("/app", "/app/name", "/config", "/zookeeper", "/zookeeper/config", "/zookeeper/quota"), + listedKeys(list) + ); + Assertions.assertTrue(list.get("continuation").isJsonNull()); + } + } + + @Test + void listPrefixListsRecursiveDescendantsOfRoot() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}")); + result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}")); + + JsonObject list = result(request(6, "kv_list_prefix", "{\"prefix\":\"/\",\"recursive\":true}")); + + Assertions.assertEquals( + List.of("/app", "/app/name", "/config", "/zookeeper", "/zookeeper/config", "/zookeeper/quota"), + listedKeys(list) + ); + } + } + + @Test + void listPrefixReturnsEmptyResultForMissingPrefix() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + + JsonObject list = result(request(7, "kv_list_prefix", "{\"prefix\":\"/missing\"}")); + + Assertions.assertEquals(List.of(), listedKeys(list)); + Assertions.assertTrue(list.get("continuation").isJsonNull()); + } + } + + @Test + void listPrefixPaginatesBySortedPath() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/c\",\"value\":{\"encoding\":\"utf8\",\"data\":\"c\"}}")); + result(request(3, "kv_put", "{\"key\":\"/a\",\"value\":{\"encoding\":\"utf8\",\"data\":\"a\"}}")); + result(request(4, "kv_put", "{\"key\":\"/b\",\"value\":{\"encoding\":\"utf8\",\"data\":\"b\"}}")); + + JsonObject first = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\",\"limit\":2}")); + String continuation = first.get("continuation").getAsString(); + JsonObject second = result(request( + 6, + "kv_list_prefix", + "{\"prefix\":\"/\",\"limit\":2,\"continuation\":\"" + continuation + "\"}" + )); + String secondContinuation = second.get("continuation").getAsString(); + JsonObject third = result(request( + 7, + "kv_list_prefix", + "{\"prefix\":\"/\",\"limit\":2,\"continuation\":\"" + secondContinuation + "\"}" + )); + + Assertions.assertEquals(List.of("/a", "/b"), listedKeys(first)); + Assertions.assertFalse(continuation.isBlank()); + Assertions.assertEquals(List.of("/c", "/zookeeper"), listedKeys(second)); + Assertions.assertFalse(secondContinuation.isBlank()); + Assertions.assertEquals(List.of("/zookeeper/config", "/zookeeper/quota"), listedKeys(third)); + Assertions.assertTrue(third.get("continuation").isJsonNull()); + } + } + + @Test + void listPrefixPaginatesDirectChildrenWhenRecursiveIsFalse() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/c/deep\",\"value\":{\"encoding\":\"utf8\",\"data\":\"c\"}}")); + result(request(3, "kv_put", "{\"key\":\"/app/a/deep\",\"value\":{\"encoding\":\"utf8\",\"data\":\"a\"}}")); + result(request(4, "kv_put", "{\"key\":\"/app/b/deep\",\"value\":{\"encoding\":\"utf8\",\"data\":\"b\"}}")); + + JsonObject first = result(request(5, "kv_list_prefix", "{\"prefix\":\"/app\",\"recursive\":false,\"limit\":2}")); + String continuation = first.get("continuation").getAsString(); + JsonObject second = result(request( + 6, + "kv_list_prefix", + "{\"prefix\":\"/app\",\"recursive\":false,\"limit\":2,\"continuation\":\"" + continuation + "\"}" + )); + + Assertions.assertEquals(List.of("/app/a", "/app/b"), listedKeys(first)); + Assertions.assertFalse(continuation.isBlank()); + Assertions.assertEquals(List.of("/app/c"), listedKeys(second)); + Assertions.assertTrue(second.get("continuation").isJsonNull()); + } + } + + @Test + void listPrefixRejectsContinuationForDifferentRequest() throws Exception { + try (TestingServer server = new TestingServer()) { + connect(server); + result(request(2, "kv_put", "{\"key\":\"/app/a\",\"value\":{\"encoding\":\"utf8\",\"data\":\"a\"}}")); + result(request(3, "kv_put", "{\"key\":\"/app/b\",\"value\":{\"encoding\":\"utf8\",\"data\":\"b\"}}")); + + JsonObject first = result(request(4, "kv_list_prefix", "{\"prefix\":\"/app\",\"limit\":1}")); + String continuation = first.get("continuation").getAsString(); + JsonObject error = error(request( + 5, + "kv_list_prefix", + "{\"prefix\":\"/\",\"limit\":1,\"continuation\":\"" + continuation + "\"}" + )); + + Assertions.assertEquals("Continuation does not match request", error.get("message").getAsString()); + } + } + + private static JsonObject connect(TestingServer server) { + return result(request(1, "connect", "{\"connection\":{\"connect_string\":\"" + server.getConnectString() + "\"}}")); + } + + private static String request(int id, String method, String params) { + return ZooKeeperAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}" + ); + } + + private static JsonObject result(String response) { + return JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result"); + } + + private static JsonObject error(String response) { + JsonObject object = JsonParser.parseString(response).getAsJsonObject(); + Assertions.assertTrue(object.has("error"), "expected JSON-RPC error but got " + response); + return object.getAsJsonObject("error"); + } + + private static String requiredString(JsonObject object, String key) { + Assertions.assertTrue(object.has(key), "expected result to contain " + key); + return object.get(key).getAsString(); + } + + private static List listedKeys(JsonObject listResult) { + List keys = new ArrayList<>(); + JsonArray rows = listResult.getAsJsonArray("keys"); + for (JsonElement row : rows) { + keys.add(row.getAsJsonObject().get("key").getAsString()); + } + return keys; + } + + private static JsonObject listedRow(JsonObject listResult, String key) { + JsonArray rows = listResult.getAsJsonArray("keys"); + for (JsonElement row : rows) { + JsonObject object = row.getAsJsonObject(); + if (key.equals(object.get("key").getAsString())) { + return object; + } + } + Assertions.fail("expected listed row for " + key); + return new JsonObject(); + } +} diff --git a/agents/gradle/wrapper/gradle-wrapper.jar b/agents/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/agents/gradle/wrapper/gradle-wrapper.jar differ diff --git a/agents/gradle/wrapper/gradle-wrapper.properties b/agents/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b52fb7e --- /dev/null +++ b/agents/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/agents/gradlew b/agents/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/agents/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/agents/gradlew.bat b/agents/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/agents/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/agents/metadata-constraint-coverage.tsv b/agents/metadata-constraint-coverage.tsv new file mode 100644 index 0000000..e8b59c4 --- /dev/null +++ b/agents/metadata-constraint-coverage.tsv @@ -0,0 +1,24 @@ +driver strategy scope reason +access intentional-fallback java-jdbc-metadata Uses Access JDBC metadata; no portable stable server-side paging API, common constraints filter locally. +bigquery native-pushdown java-sql Uses dataset INFORMATION_SCHEMA.TABLES with type, normal-name filter, stable order, and literal LIMIT/OFFSET. +cassandra intentional-fallback java-cql Uses Cassandra system_schema; portable LIKE/fuzzy paging is not safe across versions, common constraints filter locally. +dameng native-pushdown java-sql Uses ALL_OBJECTS with type, filter, stable order, and LIMIT/OFFSET with legacy fallback. +databend native-pushdown java-sql Uses system procedures for routine pushdown and shared table metadata constraints for table-like objects. +db2 native-pushdown java-sql Uses SYSCAT metadata with type, filter, stable order, and OFFSET/FETCH. +gbase8a native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET. +gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST. +goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET. +h2 native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and LIMIT/OFFSET. +hive intentional-fallback java-sql Uses Hive metadata paths where stable server-side fuzzy paging is not portable, common constraints filter locally. +informix native-pushdown java-sql Uses systables/sysprocedures with type, filter, stable order, and SKIP/FIRST. +kingbase native-pushdown java-sql Uses sys_catalog/information_schema with type, filter, stable order, and LIMIT/OFFSET for both compatibility modes. +kylin intentional-fallback java-jdbc-metadata Uses JDBC metadata from Kylin driver; no portable server-side paging API, common constraints filter locally. +neo4j intentional-fallback java-graph-metadata Uses JDBC metadata with Cypher fallback; label discovery cannot guarantee relational-style filtered paging, common constraints filter locally. +oceanbase-oracle native-pushdown java-sql Uses Oracle-compatible metadata SQL with type, filter, stable order, and ROWNUM paging. +oracle-go native-pushdown native-go Uses Oracle-compatible metadata SQL with type, filter, stable order, and ROWNUM paging, with legacy profile guard. +snowflake native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and literal LIMIT/OFFSET. +spark intentional-fallback java-show-metadata Uses SHOW TABLES/SHOW DATABASES; stable server-side fuzzy paging is not portable, common constraints filter locally. +sundb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET. +tdengine intentional-fallback java-show-metadata Uses SHOW STABLES/TABLES; stable server-side fuzzy paging is not portable, common constraints filter locally. +trino native-pushdown java-sql Uses information_schema.tables with type, filter, stable order, and literal LIMIT/OFFSET. +xugu native-pushdown native-go Uses ALL_TABLES/ALL_VIEWS metadata with type, filter, stable order, and ROWNUM paging. diff --git a/agents/opengauss/build.gradle b/agents/opengauss/build.gradle new file mode 100644 index 0000000..b8b3af7 --- /dev/null +++ b/agents/opengauss/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.opengauss:opengauss-jdbc:7.0.0-RC3-og' +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'openGauss', 'Main-Class': 'com.dbx.agent.opengauss.OpenGaussAgent') + } +} diff --git a/agents/opengauss/src/main/java/com/dbx/agent/opengauss/OpenGaussAgent.java b/agents/opengauss/src/main/java/com/dbx/agent/opengauss/OpenGaussAgent.java new file mode 100644 index 0000000..985b964 --- /dev/null +++ b/agents/opengauss/src/main/java/com/dbx/agent/opengauss/OpenGaussAgent.java @@ -0,0 +1,20 @@ +package com.dbx.agent.opengauss; + +import com.dbx.agent.JsonRpcServer; +import com.dbx.agent.PostgresLikeAgent; +import com.dbx.agent.PostgresLikeAgentProfile; + +public final class OpenGaussAgent extends PostgresLikeAgent { + public static final PostgresLikeAgentProfile OPENGAUSS_PROFILE = new PostgresLikeAgentProfile( + "org.opengauss.Driver", + "jdbc:opengauss://{host}:{port}/{database}" + ); + + public OpenGaussAgent() { + super(OPENGAUSS_PROFILE); + } + + public static void main(String[] args) { + new JsonRpcServer(new OpenGaussAgent()).run(); + } +} diff --git a/agents/opengauss/src/test/java/com/dbx/agent/opengauss/OpenGaussAgentTest.java b/agents/opengauss/src/test/java/com/dbx/agent/opengauss/OpenGaussAgentTest.java new file mode 100644 index 0000000..8b8452d --- /dev/null +++ b/agents/opengauss/src/test/java/com/dbx/agent/opengauss/OpenGaussAgentTest.java @@ -0,0 +1,21 @@ +package com.dbx.agent.opengauss; + +import com.dbx.agent.ConnectParams; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class OpenGaussAgentTest { + @Test + void buildsOpenGaussUrl() { + ConnectParams params = new ConnectParams(); + params.setHost("db.example.com"); + params.setPort(15432); + params.setDatabase("postgres"); + + assertEquals( + "jdbc:opengauss://db.example.com:15432/postgres", + OpenGaussAgent.OPENGAUSS_PROFILE.buildUrl(params) + ); + } +} diff --git a/agents/scripts/build_offline_zip.sh b/agents/scripts/build_offline_zip.sh new file mode 100755 index 0000000..9039324 --- /dev/null +++ b/agents/scripts/build_offline_zip.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build offline ZIP bundles for each platform. +# Usage: ./scripts/build_offline_zip.sh +# The release-dir must contain agent-registry.json, dbx-agent-*.jar, and dbx-jre-*.tar.gz + +RELEASE_DIR="$(cd "${1:?Usage: build_offline_zip.sh }" && pwd)" + +if [ ! -f "$RELEASE_DIR/agent-registry.json" ]; then + echo "ERROR: agent-registry.json not found in $RELEASE_DIR" + exit 1 +fi + +PLATFORMS=( + "macos-aarch64" + "macos-x64" + "linux-x64" + "linux-aarch64" + "windows-x64" + "windows-aarch64" +) + +STAGING=$(mktemp -d) +trap 'rm -rf "$STAGING"' EXIT + +for platform in "${PLATFORMS[@]}"; do + WORK="$STAGING/$platform" + mkdir -p "$WORK/jre" "$WORK/drivers" + + cp "$RELEASE_DIR/agent-registry.json" "$WORK/" + + # Copy all JRE archives for this platform + JRE_COUNT=0 + for jre_file in "$RELEASE_DIR"/dbx-jre-*-"$platform".tar.gz; do + [ -f "$jre_file" ] || continue + cp "$jre_file" "$WORK/jre/" + JRE_COUNT=$((JRE_COUNT + 1)) + done + + if [ "$JRE_COUNT" -eq 0 ]; then + echo "SKIP $platform (no JRE found)" + rm -rf "$WORK" + continue + fi + + # Copy all driver JARs (platform-independent) + for jar_file in "$RELEASE_DIR"/dbx-agent-*.jar; do + [ -f "$jar_file" ] || continue + cp "$jar_file" "$WORK/drivers/" + done + + for native_file in "$RELEASE_DIR"/dbx-agent-*-"$platform" "$RELEASE_DIR"/dbx-agent-*-"$platform".exe; do + [ -f "$native_file" ] || continue + cp "$native_file" "$WORK/drivers/" + done + + ZIP_NAME="dbx-agents-offline-${platform}.zip" + (cd "$WORK" && zip -r "$RELEASE_DIR/$ZIP_NAME" agent-registry.json jre/ drivers/) + SIZE=$(du -h "$RELEASE_DIR/$ZIP_NAME" | cut -f1) + echo "Created $ZIP_NAME ($SIZE)" +done + +echo "Done." diff --git a/agents/scripts/release.sh b/agents/scripts/release.sh new file mode 100755 index 0000000..2c261ba --- /dev/null +++ b/agents/scripts/release.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${ROOT_DIR}" +exec node scripts/release.mjs agents "$@" diff --git a/agents/scripts/validate_agent_jars.py b/agents/scripts/validate_agent_jars.py new file mode 100644 index 0000000..a51e329 --- /dev/null +++ b/agents/scripts/validate_agent_jars.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import sys +import zipfile +from email.parser import Parser +from pathlib import Path + + +def agent_modules(root: Path) -> list[str]: + return sorted( + path.parents[2].name + for path in list(root.glob("*/build/libs/dbx-agent-*.jar")) + + list(root.glob("drivers/*/build/libs/dbx-agent-*.jar")) + if path.is_file() + ) + + +def module_dir(root: Path, module: str) -> Path: + nested = root / "drivers" / module + if nested.exists(): + return nested + return root / module + + +def validate_agent_jars(root: Path) -> list[str]: + problems: list[str] = [] + for module in agent_modules(root): + jar = module_dir(root, module) / "build/libs" / f"dbx-agent-{module}.jar" + relative = jar.relative_to(root) + try: + with zipfile.ZipFile(jar) as archive: + manifest = read_manifest(archive) + main_class = manifest.get("Main-Class") + if not main_class: + problems.append(f"{relative}: missing Main-Class manifest attribute") + continue + class_entry = main_class.replace(".", "/") + ".class" + if class_entry not in archive.namelist(): + problems.append(f"{relative}: Main-Class class not found: {class_entry}") + if not manifest.get("Agent-Label"): + problems.append(f"{relative}: missing Agent-Label manifest attribute") + except zipfile.BadZipFile: + problems.append(f"{relative}: invalid jar") + return problems + + +def read_manifest(archive: zipfile.ZipFile) -> dict[str, str]: + try: + raw = archive.read("META-INF/MANIFEST.MF").decode("utf-8") + except KeyError: + return {} + return dict(Parser().parsestr(raw)) + + +def main() -> int: + root = Path.cwd() + problems = validate_agent_jars(root) + if problems: + print("Agent jar validation failed:") + for problem in problems: + print(f"- {problem}") + return 1 + + print("Agent jar validation passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/scripts/validate_agents.py b/agents/scripts/validate_agents.py new file mode 100644 index 0000000..33b1758 --- /dev/null +++ b/agents/scripts/validate_agents.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +import json +import re +import sys +from pathlib import Path +from typing import Optional + + +INFRA_MODULES = {"common", "test-support"} +SOURCE_GLOBS = ("*/src/main/**/*.java", "drivers/*/src/main/**/*.java") +KOTLIN_FILE_SUFFIXES = (".kt", ".kts") +KOTLIN_SCAN_EXCLUDED_PARTS = {".git", ".gradle", "build"} +DEFAULT_AGENT_JRE_KEY = "21" +NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka"} +NATIVE_ONLY_AGENT_MODULES = { + "oracle": "drivers/oracle-go", + "xugu": "drivers/xugu", +} +JDBC_ARCHITECTURE_ALLOWLIST = { + "h2-legacy": "reuses the H2 agent implementation with an isolated legacy driver version", + "access": "custom Access metadata and URL behavior pending migration", + "dameng": "custom Dameng metadata and DDL pending migration", + "db2": "custom DB2 metadata pending migration", + "gbase8s": "custom GBase 8s metadata pending migration", + "goldendb": "custom GoldenDB metadata pending migration", + "informix": "custom Informix metadata pending migration", + "neo4j": "custom Neo4j transaction/query behavior pending migration", + "sundb": "custom SunDB metadata pending migration", + "tdengine": "custom TDengine WebSocket JDBC behavior pending migration", +} +APPROVED_JDBC_BASES = { + "AbstractJdbcAgent", + "ConfiguredJdbcAgent", + "PostgresLikeAgent", +} + +FORBIDDEN_PATTERNS = [ + (re.compile(r"private\s+val\s+QUERY_PREFIXES"), "forbidden local SQL prefix classifier"), + (re.compile(r"private\s+const\s+val\s+MAX_ROWS"), "forbidden local result row cap"), + (re.compile(r"executeUpdate\s*\(\s*trimmedSql\s*\)"), "forbidden executeUpdate(trimmedSql) in query execution"), + (re.compile(r"truncated\s*=\s*rows\.size\s*>=\s*MAX_ROWS"), "forbidden inclusive truncation check"), + (re.compile(r"val\s+truncated\s*=\s*rs\.next\s*\(\s*\)"), "forbidden extra ResultSet next() truncation probe"), +] + +COPIED_JDBC_INFRASTRUCTURE_PATTERNS = [ + (re.compile(r"\bClass\.forName\s*\("), "copied driver loading"), + (re.compile(r"\bDriverManager\.getConnection\s*\("), "copied JDBC connection creation"), + (re.compile(r"public\s+void\s+disconnect\s*\("), "copied JDBC disconnect lifecycle"), + (re.compile(r"JdbcExecutor\.INSTANCE\.execute\s*\("), "copied JDBC query execution"), + (re.compile(r"private\s+Object\s+(?:getResultValue|resultValue|stringResultValue)\s*\("), "copied result value reader"), +] + + +def included_agent_modules(root: Path) -> set[str]: + settings = (root / "settings.gradle").read_text(encoding="utf-8") + include_args = "\n".join( + parenthesized or bare + for parenthesized, bare in re.findall( + r"\binclude\b\s*(?:\((.*?)\)|([^\n]+))", settings, flags=re.S + ) + ) + included = set(re.findall(r"""['"]([^'"]+)['"]""", include_args)) + for variable in ("infrastructureModules", "driverModules"): + match = re.search(rf"\bdef\s+{variable}\s*=\s*\[(.*?)\]", settings, flags=re.S) + if match: + included.update(re.findall(r"""['"]([^'"]+)['"]""", match.group(1))) + return included - INFRA_MODULES + + +def agent_modules(root: Path) -> set[str]: + native = {name for name, path in NATIVE_ONLY_AGENT_MODULES.items() if (root / path).exists()} + return included_agent_modules(root) | native + + +def module_dir(root: Path, module: str) -> Path: + if module in NATIVE_ONLY_AGENT_MODULES: + return root / NATIVE_ONLY_AGENT_MODULES[module] + nested = root / "drivers" / module + if nested.exists(): + return nested + return root / module + + +def module_relative_path(root: Path, module: str, *parts: str) -> Path: + return module_dir(root, module).joinpath(*parts) + + +def validate_versions(root: Path) -> list[str]: + included = agent_modules(root) + versions = set(json.loads((root / "versions.json").read_text(encoding="utf-8"))) + return ( + [f"included module missing version: {name}" for name in sorted(included - versions)] + + [f"versions key not included: {name}" for name in sorted(versions - included)] + ) + + +def validate_source_patterns(root: Path) -> list[str]: + problems: list[str] = [] + sources = sorted(source for glob in SOURCE_GLOBS for source in root.glob(glob)) + for source in sources: + relative = source.relative_to(root) + text = source.read_text(encoding="utf-8") + for line_number, line in enumerate(text.splitlines(), start=1): + for pattern, message in FORBIDDEN_PATTERNS: + if pattern.search(line): + problems.append(f"{relative}:{line_number}: {message}") + return problems + + +def validate_no_kotlin_residue(root: Path) -> list[str]: + problems: list[str] = [] + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(root) + if any(part in KOTLIN_SCAN_EXCLUDED_PARTS for part in relative.parts): + continue + if path.suffix in KOTLIN_FILE_SUFFIXES: + problems.append(f"{relative}: forbidden Kotlin file") + return problems + + +def validate_manifest_fields(root: Path, modules: set[str]) -> list[str]: + problems: list[str] = [] + root_build_text = (root / "build.gradle").read_text(encoding="utf-8") if (root / "build.gradle").exists() else "" + archive_convention = re.search( + r"archiveBaseName\s*=\s*['\"]dbx-agent-\$\{project\.name\}['\"]", + root_build_text, + ) + for module in sorted(modules): + if module in NATIVE_ONLY_AGENT_MODULES: + continue + build_file = module_relative_path(root, module, "build.gradle") + relative = build_file.relative_to(root) + if not build_file.exists(): + problems.append(f"{module}: missing build.gradle") + continue + + text = build_file.read_text(encoding="utf-8") + expected_archive = f"archiveBaseName = 'dbx-agent-{module}'" + archive_pattern = re.compile( + rf"archiveBaseName\s*=\s*['\"]dbx-agent-{re.escape(module)}['\"]" + ) + if not archive_convention and not archive_pattern.search(text): + problems.append(f"{relative}: missing {expected_archive}") + attrs = manifest_attributes(text) + if "Agent-Label" not in attrs: + problems.append(f"{relative}: missing Agent-Label manifest attribute") + main_class = attrs.get("Main-Class") + if main_class is None: + problems.append(f"{relative}: missing Main-Class manifest attribute") + else: + main_source = module_relative_path( + root, + module, + "src/main/java", + str(Path(*main_class.split(".")).with_suffix(".java")), + ) + if not main_source.exists(): + problems.append(f"{relative}: Main-Class source not found: {main_source.relative_to(root)}") + return problems + + +def validate_jdbc_architecture(root: Path, modules: set[str]) -> list[str]: + problems: list[str] = [] + for module in sorted(modules - NON_JDBC_AGENT_MODULES - set(NATIVE_ONLY_AGENT_MODULES)): + source = main_class_source(root, module) + if source is None or not source.exists(): + continue + text = source.read_text(encoding="utf-8") + base_match = re.search(r"\bextends\s+([A-Za-z0-9_]+)", text) + base = base_match.group(1) if base_match else "" + if module not in JDBC_ARCHITECTURE_ALLOWLIST and base not in APPROVED_JDBC_BASES: + problems.append( + f"{source.relative_to(root)}: JDBC agents must extend AbstractJdbcAgent, ConfiguredJdbcAgent, or PostgresLikeAgent" + ) + if module in JDBC_ARCHITECTURE_ALLOWLIST: + continue + for line_number, line in enumerate(text.splitlines(), start=1): + for pattern, message in COPIED_JDBC_INFRASTRUCTURE_PATTERNS: + if pattern.search(line): + problems.append(f"{source.relative_to(root)}:{line_number}: {message}; use shared JDBC foundation") + return problems + + +def validate_authoring_template(root: Path) -> list[str]: + template = root / "docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java" + if not template.exists(): + return [] + text = template.read_text(encoding="utf-8") + problems: list[str] = [] + if "extends BaseDatabaseAgent" in text: + problems.append(f"{template.relative_to(root)}: template must use shared JDBC foundation") + for pattern, message in COPIED_JDBC_INFRASTRUCTURE_PATTERNS: + if pattern.search(text): + problems.append(f"{template.relative_to(root)}: template contains {message}") + return problems + + +def main_class_source(root: Path, module: str) -> Optional[Path]: + build_file = module_relative_path(root, module, "build.gradle") + if not build_file.exists(): + return None + attrs = manifest_attributes(build_file.read_text(encoding="utf-8")) + main_class = attrs.get("Main-Class") + if main_class is None: + return None + return module_relative_path(root, module, "src/main/java", str(Path(*main_class.split(".")).with_suffix(".java"))) + + +def validate_release_runtime_keys(root: Path) -> list[str]: + workflow_candidates = [ + root.parent / ".github/workflows/agents-release.yml", + root / ".github/workflows/agents-release.yml", + root / ".github/workflows/release.yml", + ] + workflow = next((candidate for candidate in workflow_candidates if candidate.exists()), workflow_candidates[-1]) + if not workflow.exists(): + return [] + text = workflow.read_text(encoding="utf-8") + problems: list[str] = [] + required_patterns = [ + ( + rf'jre-key:\s*"{DEFAULT_AGENT_JRE_KEY}"', + f"release workflow must build the default JRE with key {DEFAULT_AGENT_JRE_KEY}", + ), + ( + rf'java-version:\s*"{DEFAULT_AGENT_JRE_KEY}"', + f"release workflow must build the default JRE from Java {DEFAULT_AGENT_JRE_KEY}", + ), + ( + rf'\*\)\s*echo\s*"{DEFAULT_AGENT_JRE_KEY}"', + f"agents must use JRE key {DEFAULT_AGENT_JRE_KEY}", + ), + ( + rf'"{DEFAULT_AGENT_JRE_KEY}":\s*\{{\s*"version":\s*"{DEFAULT_AGENT_JRE_KEY}\.', + f"registry must publish Java {DEFAULT_AGENT_JRE_KEY} under JRE key {DEFAULT_AGENT_JRE_KEY}", + ), + ( + r"jdk\.security\.auth", + "release workflow JRE must include jdk.security.auth for Kafka Kerberos LoginModule support", + ), + ( + r"jdk\.security\.jgss", + "release workflow JRE must include jdk.security.jgss for Kafka GSSAPI SASL support", + ), + ( + r"legacy-placeholder\.jar", + "native-only registry entries must publish a legacy jar placeholder for older DBX clients", + ), + ] + for pattern, message in required_patterns: + if not re.search(pattern, text, flags=re.S): + problems.append(message) + forbidden_patterns = [ + (r'jre-key:\s*"17"', "release workflow must not build Java 21 under JRE key 17"), + (r'\*\)\s*echo\s*"17"', "agents must not use JRE key 17"), + (r'"17":\s*\{\s*"version":\s*"21\.', "registry must not publish Java 21 under JRE key 17"), + ] + for pattern, message in forbidden_patterns: + if re.search(pattern, text, flags=re.S): + problems.append(message) + return problems + + +def manifest_attributes(build_text: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for key, value in re.findall( + r"['\"]([^'\"]+)['\"]\s*:\s*['\"]([^'\"]+)['\"]", + build_text, + ): + attrs[key] = value + return attrs + + +def validate(root: Path) -> list[str]: + modules = agent_modules(root) + return ( + validate_versions(root) + + validate_source_patterns(root) + + validate_manifest_fields(root, modules) + + validate_jdbc_architecture(root, modules) + + validate_authoring_template(root) + + validate_release_runtime_keys(root) + + validate_no_kotlin_residue(root) + ) + + +def main() -> int: + root = Path.cwd() + problems = validate(root) + if problems: + print("Agent validation failed:") + for problem in problems: + print(f"- {problem}") + return 1 + + print("Agent validation passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/scripts/validate_agents_test.py b/agents/scripts/validate_agents_test.py new file mode 100644 index 0000000..9354375 --- /dev/null +++ b/agents/scripts/validate_agents_test.py @@ -0,0 +1,427 @@ +import json +import tempfile +import textwrap +import unittest +import zipfile +from pathlib import Path + +import validate_agents +import validate_agent_jars + + +class ValidateAgentsTest(unittest.TestCase): + def test_versions_must_match_included_agent_modules(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "settings.gradle").write_text( + "include 'common', 'test-support', 'h2', 'oracle'\n", + encoding="utf-8", + ) + (root / "versions.json").write_text( + json.dumps({"h2": "0.1.0", "mongodb": "0.1.0"}), + encoding="utf-8", + ) + + problems = validate_agents.validate_versions(root) + + self.assertEqual( + [ + "included module missing version: oracle", + "versions key not included: mongodb", + ], + problems, + ) + + def test_versions_support_driver_modules_variable(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "settings.gradle").write_text( + "def infrastructureModules = ['common', 'test-support']\n" + "def driverModules = ['h2', 'oracle']\n" + "include(*(infrastructureModules + driverModules))\n", + encoding="utf-8", + ) + (root / "versions.json").write_text( + json.dumps({"h2": "0.1.0", "oracle": "0.1.0"}), + encoding="utf-8", + ) + + self.assertEqual([], validate_agents.validate_versions(root)) + + def test_source_scan_rejects_old_execute_query_patterns(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "h2/src/main/java/com/dbx/agent/h2/H2Agent.java" + source.parent.mkdir(parents=True) + source.write_text( + textwrap.dedent( + """ + private val QUERY_PREFIXES = listOf("SELECT") + fun executeQuery(sql: String) { + val trimmedSql = sql.trim() + stmt.executeUpdate(trimmedSql) + } + """ + ), + encoding="utf-8", + ) + + problems = validate_agents.validate_source_patterns(root) + + self.assertEqual( + [ + "h2/src/main/java/com/dbx/agent/h2/H2Agent.java:2: forbidden local SQL prefix classifier", + "h2/src/main/java/com/dbx/agent/h2/H2Agent.java:5: forbidden executeUpdate(trimmedSql) in query execution", + ], + problems, + ) + + def test_jdbc_architecture_requires_shared_base_for_new_agents(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + module = root / "example" + source = module / "src/main/java/com/dbx/agent/example/ExampleAgent.java" + source.parent.mkdir(parents=True) + (module / "build.gradle").write_text( + textwrap.dedent( + """ + tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'Example', + 'Main-Class': 'com.dbx.agent.example.ExampleAgent' + ) + } + } + """ + ), + encoding="utf-8", + ) + source.write_text( + textwrap.dedent( + """ + package com.dbx.agent.example; + + import com.dbx.agent.BaseDatabaseAgent; + import java.sql.DriverManager; + + public final class ExampleAgent extends BaseDatabaseAgent { + public void connect() throws Exception { + Class.forName("example.Driver"); + DriverManager.getConnection("jdbc:example"); + } + } + """ + ), + encoding="utf-8", + ) + + problems = validate_agents.validate_jdbc_architecture(root, {"example"}) + + self.assertEqual( + [ + "example/src/main/java/com/dbx/agent/example/ExampleAgent.java: JDBC agents must extend AbstractJdbcAgent, ConfiguredJdbcAgent, or PostgresLikeAgent", + "example/src/main/java/com/dbx/agent/example/ExampleAgent.java:9: copied driver loading; use shared JDBC foundation", + "example/src/main/java/com/dbx/agent/example/ExampleAgent.java:10: copied JDBC connection creation; use shared JDBC foundation", + ], + problems, + ) + + def test_versions_include_native_only_modules(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "settings.gradle").write_text( + "def infrastructureModules = ['common', 'test-support']\n" + "def driverModules = ['h2']\n" + "include(*(infrastructureModules + driverModules))\n", + encoding="utf-8", + ) + (root / "drivers/oracle-go").mkdir(parents=True) + (root / "versions.json").write_text( + json.dumps({"h2": "0.1.0", "oracle": "0.1.0"}), + encoding="utf-8", + ) + + self.assertEqual([], validate_agents.validate_versions(root)) + + def test_authoring_template_must_use_shared_foundation(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + template = root / "docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java" + template.parent.mkdir(parents=True) + template.write_text( + textwrap.dedent( + """ + package com.dbx.agent.template; + import com.dbx.agent.BaseDatabaseAgent; + import java.sql.DriverManager; + public final class TemplateAgent extends BaseDatabaseAgent { + void connect() throws Exception { + DriverManager.getConnection("jdbc:template"); + } + } + """ + ), + encoding="utf-8", + ) + + self.assertEqual( + [ + "docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java: template must use shared JDBC foundation", + "docs/examples/jdbc-agent-template/src/main/java/com/dbx/agent/template/TemplateAgent.java: template contains copied JDBC connection creation", + ], + validate_agents.validate_authoring_template(root), + ) + + def test_manifest_validation_requires_registry_fields(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + module = root / "h2" + module.mkdir() + (root / "build.gradle").write_text( + textwrap.dedent( + """ + configure(agentProjects) { + tasks.named('shadowJar') { + archiveBaseName = "dbx-agent-${project.name}" + } + } + """ + ), + encoding="utf-8", + ) + (module / "build.gradle").write_text( + textwrap.dedent( + """ + tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'H2' + ) + } + } + """ + ), + encoding="utf-8", + ) + + problems = validate_agents.validate_manifest_fields(root, {"h2"}) + + self.assertEqual( + ["h2/build.gradle: missing Main-Class manifest attribute"], + problems, + ) + + def test_manifest_validation_requires_main_class_source(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + module = root / "h2" + module.mkdir() + (root / "build.gradle").write_text( + 'archiveBaseName = "dbx-agent-${project.name}"\n', + encoding="utf-8", + ) + (module / "build.gradle").write_text( + textwrap.dedent( + """ + tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'H2', + 'Main-Class': 'com.dbx.agent.h2.H2Agent' + ) + } + } + """ + ), + encoding="utf-8", + ) + + problems = validate_agents.validate_manifest_fields(root, {"h2"}) + + self.assertEqual( + ["h2/build.gradle: Main-Class source not found: h2/src/main/java/com/dbx/agent/h2/H2Agent.java"], + problems, + ) + + source = module / "src/main/java/com/dbx/agent/h2/H2Agent.java" + source.parent.mkdir(parents=True) + source.write_text("package com.dbx.agent.h2; public final class H2Agent {}", encoding="utf-8") + + self.assertEqual([], validate_agents.validate_manifest_fields(root, {"h2"})) + + def test_manifest_validation_supports_drivers_directory(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + module = root / "drivers/h2" + source = module / "src/main/java/com/dbx/agent/h2/H2Agent.java" + source.parent.mkdir(parents=True) + source.write_text("package com.dbx.agent.h2; public final class H2Agent {}", encoding="utf-8") + (root / "build.gradle").write_text( + 'archiveBaseName = "dbx-agent-${project.name}"\n', + encoding="utf-8", + ) + (module / "build.gradle").write_text( + textwrap.dedent( + """ + tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'H2', + 'Main-Class': 'com.dbx.agent.h2.H2Agent' + ) + } + } + """ + ), + encoding="utf-8", + ) + + self.assertEqual([], validate_agents.validate_manifest_fields(root, {"h2"})) + + def test_kotlin_residue_scan_rejects_kt_and_kts_files_outside_build_dirs(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + forbidden_source = root / "h2/src/main/kotlin/com/dbx/agent/h2/H2Agent.kt" + forbidden_build = root / "settings.gradle.kts" + ignored_build_output = root / "h2/build/tmp/Generated.kt" + ignored_gradle_cache = root / ".gradle/caches/init.gradle.kts" + for path in ( + forbidden_source, + forbidden_build, + ignored_build_output, + ignored_gradle_cache, + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + + problems = validate_agents.validate_no_kotlin_residue(root) + + self.assertEqual( + [ + "h2/src/main/kotlin/com/dbx/agent/h2/H2Agent.kt: forbidden Kotlin file", + "settings.gradle.kts: forbidden Kotlin file", + ], + problems, + ) + + def test_release_runtime_keys_match_java_21_default(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/release.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + textwrap.dedent( + ''' + strategy: + matrix: + include: + - jre-key: "21" + java-version: "21" + modules: "java.base,jdk.security.auth,jdk.security.jgss" + detect_jre_key() { + case "$name" in + *) echo "21" ;; + esac + } + cat > release/agent-registry.json < release/agent-registry.json < + project(":${name}").projectDir = file("drivers/${name}") +} diff --git a/agents/test-support/build.gradle b/agents/test-support/build.gradle new file mode 100644 index 0000000..0769fc7 --- /dev/null +++ b/agents/test-support/build.gradle @@ -0,0 +1,4 @@ +dependencies { + api 'org.junit.jupiter:junit-jupiter-api:5.11.4' + implementation project(':common') +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcAgentFake.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcAgentFake.java new file mode 100644 index 0000000..7665fea --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcAgentFake.java @@ -0,0 +1,177 @@ +package com.dbx.agent.test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +public final class JdbcAgentFake { + public static final JdbcAgentFake INSTANCE = new JdbcAgentFake(); + public static final List calls = new ArrayList(); + + private JdbcAgentFake() { + } + + public static Connection connection() { + calls.clear(); + Statement statement = statement(); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("createStatement".equals(name)) { + return statement; + } + if ("getAutoCommit".equals(name)) { + return true; + } + if ("setAutoCommit".equals(name) || "commit".equals(name) || "rollback".equals(name) || "close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + } + }); + } + + public List getCalls() { + return calls; + } + + private static Statement statement() { + ResultSet resultSet = resultSet(); + return proxy(Statement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("execute".equals(name)) { + calls.add("execute"); + return true; + } + if ("executeQuery".equals(name)) { + calls.add("executeQuery"); + return resultSet; + } + if ("executeUpdate".equals(name)) { + calls.add("executeUpdate"); + return 0; + } + if ("getResultSet".equals(name)) { + return resultSet; + } + if ("getUpdateCount".equals(name)) { + return 0; + } + if ("setMaxRows".equals(name)) { + calls.add("setMaxRows:" + args[0]); + return null; + } + if ("setFetchSize".equals(name)) { + calls.add("setFetchSize:" + args[0]); + return null; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet resultSet() { + final int[] index = {-1}; + ResultSetMetaData metadata = metadata(); + return proxy(ResultSet.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] == 0; + } + if ("getMetaData".equals(name)) { + return metadata; + } + if ("getObject".equals(name) || "getString".equals(name)) { + return "row-value"; + } + if ("wasNull".equals(name)) { + return false; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSetMetaData metadata() { + return proxy(ResultSetMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("getColumnCount".equals(name)) { + return 1; + } + if ("getColumnLabel".equals(name)) { + return "VALUE"; + } + if ("getColumnType".equals(name)) { + return Types.VARCHAR; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static T proxy(Class type, final MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcConnectedAgentTest.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcConnectedAgentTest.java new file mode 100644 index 0000000..664e3cb --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcConnectedAgentTest.java @@ -0,0 +1,18 @@ +package com.dbx.agent.test; + +import com.dbx.agent.DatabaseAgent; + +import java.util.function.Consumer; + +public abstract class JdbcConnectedAgentTest { + protected abstract DatabaseAgent createConnectedAgent(String databaseName); + + protected void withAgent(String databaseName, Consumer block) { + DatabaseAgent agent = createConnectedAgent(databaseName); + try { + block.accept(agent); + } finally { + agent.disconnect(); + } + } +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcExecutionBehaviorTest.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcExecutionBehaviorTest.java new file mode 100644 index 0000000..6764a5f --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcExecutionBehaviorTest.java @@ -0,0 +1,174 @@ +package com.dbx.agent.test; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public abstract class JdbcExecutionBehaviorTest extends JdbcConnectedAgentTest { + protected abstract String resultSetSql(); + + protected abstract List expectedResultSetColumns(); + + protected abstract List> expectedResultSetRows(); + + protected abstract String rowsSql(int rowCount); + + @Test + void executesStatementsThatReturnResultSetsWithoutSelectPrefix() { + withAgent("dbx-agent-result-set", agent -> { + QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions()); + + assertEquals(expectedResultSetColumns(), result.getColumns()); + assertEquals(expectedResultSetRows(), result.getRows()); + assertEquals(0, result.getAffected_rows()); + assertFalse(result.getTruncated()); + }); + } + + @Test + void doesNotMarkExactlyMaxRowsAsTruncated() { + withAgent("dbx-agent-limit", agent -> { + QueryResult result = agent.executeQuery(rowsSql(JdbcExecutor.DEFAULT_MAX_ROWS), null, new ExecuteQueryOptions()); + + assertEquals(JdbcExecutor.DEFAULT_MAX_ROWS, result.getRows().size()); + assertFalse(result.getTruncated()); + }); + } + + @Test + void marksResultsBeyondMaxRowsAsTruncated() { + withAgent("dbx-agent-truncated", agent -> { + QueryResult result = agent.executeQuery(rowsSql(JdbcExecutor.DEFAULT_MAX_ROWS + 1), null, new ExecuteQueryOptions()); + + assertEquals(JdbcExecutor.DEFAULT_MAX_ROWS, result.getRows().size()); + assertTrue(result.getTruncated()); + }); + } + + @Test + void fetchesSubsequentResultPagesFromTheSameJdbcSession() { + withAgent("dbx-agent-session-pages", agent -> { + QueryPageResult first = agent.executeQueryPage(rowsSql(5), null, new QueryPageOptions(2, 2, 10)); + + assertEquals(Arrays.asList(Collections.singletonList(1L), Collections.singletonList(2L)), first.getRows()); + assertTrue(first.getHas_more()); + assertFalse(first.getTruncated()); + String sessionId = first.getSession_id(); + assertNotNull(sessionId); + + QueryPageResult second = agent.fetchQueryPage(sessionId, 2); + + assertEquals(Arrays.asList(Collections.singletonList(3L), Collections.singletonList(4L)), second.getRows()); + assertTrue(second.getHas_more()); + assertFalse(second.getTruncated()); + + QueryPageResult third = agent.fetchQueryPage(sessionId, 2); + + assertEquals(Collections.singletonList(Collections.singletonList(5L)), third.getRows()); + assertFalse(third.getHas_more()); + assertFalse(third.getTruncated()); + }); + } + + @Test + void fetchesTableReadPagesFromTheSameJdbcSession() { + withAgent("dbx-agent-table-read-session-pages", agent -> { + QueryPageResult first = agent.startTableRead(rowsSql(5), null, new QueryPageOptions(2, 2, 10)); + + assertEquals(Arrays.asList(Collections.singletonList(1L), Collections.singletonList(2L)), first.getRows()); + assertTrue(first.getHas_more()); + assertFalse(first.getTruncated()); + String sessionId = first.getSession_id(); + assertNotNull(sessionId); + + QueryPageResult second = agent.fetchTableReadPage(sessionId, 2); + + assertEquals(Arrays.asList(Collections.singletonList(3L), Collections.singletonList(4L)), second.getRows()); + assertTrue(second.getHas_more()); + assertFalse(second.getTruncated()); + + QueryPageResult third = agent.fetchTableReadPage(sessionId, 2); + + assertEquals(Collections.singletonList(Collections.singletonList(5L)), third.getRows()); + assertFalse(third.getHas_more()); + assertFalse(third.getTruncated()); + assertFalse(agent.closeTableReadSession(sessionId)); + }); + } + + @Test + void closesJdbcResultSessionsExplicitly() { + withAgent("dbx-agent-session-close", agent -> { + QueryPageResult first = agent.executeQueryPage(rowsSql(5), null, new QueryPageOptions(2, 2, 10)); + String sessionId = first.getSession_id(); + assertNotNull(sessionId); + + assertTrue(agent.closeQuerySession(sessionId)); + assertFalse(agent.closeQuerySession(sessionId)); + assertThrows(IllegalArgumentException.class, () -> agent.fetchQueryPage(sessionId, 2)); + }); + } + + @Test + void expiresIdleJdbcResultSessions() { + withAgent("dbx-agent-session-expiry", agent -> { + QueryPageResult first = agent.executeQueryPage(rowsSql(5), null, new QueryPageOptions(2, 2, 10)); + String sessionId = first.getSession_id(); + assertNotNull(sessionId); + + assertEquals(1, JdbcExecutor.INSTANCE.expireIdleQuerySessions(System.currentTimeMillis(), 0L)); + assertThrows(IllegalArgumentException.class, () -> agent.fetchQueryPage(sessionId, 2)); + }); + } + + @Test + void handlesTransactionControlStatementsConsistently() { + withAgent("dbx-agent-transaction", agent -> { + Connection connection = agent.getConnection(); + assertNotNull(connection); + + try { + QueryResult begin = agent.executeQuery("BEGIN", null, new ExecuteQueryOptions()); + assertFalse(connection.getAutoCommit()); + assertEquals(Collections.emptyList(), begin.getColumns()); + assertEquals(Collections.emptyList(), begin.getRows()); + assertEquals(0, begin.getAffected_rows()); + assertFalse(begin.getTruncated()); + + QueryResult commit = agent.executeQuery("COMMIT", null, new ExecuteQueryOptions()); + assertTrue(connection.getAutoCommit()); + assertEquals(Collections.emptyList(), commit.getColumns()); + assertEquals(Collections.emptyList(), commit.getRows()); + assertEquals(0, commit.getAffected_rows()); + assertFalse(commit.getTruncated()); + + agent.executeQuery("BEGIN", null, new ExecuteQueryOptions()); + assertFalse(connection.getAutoCommit()); + + QueryResult rollback = agent.executeQuery("ROLLBACK", null, new ExecuteQueryOptions()); + assertTrue(connection.getAutoCommit()); + assertEquals(Collections.emptyList(), rollback.getColumns()); + assertEquals(Collections.emptyList(), rollback.getRows()); + assertEquals(0, rollback.getAffected_rows()); + assertFalse(rollback.getTruncated()); + } catch (Exception e) { + throw new AssertionError(e); + } + }); + } +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcFakeExecutionBehaviorTest.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcFakeExecutionBehaviorTest.java new file mode 100644 index 0000000..18f554d --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcFakeExecutionBehaviorTest.java @@ -0,0 +1,69 @@ +package com.dbx.agent.test; + +import com.dbx.agent.DatabaseAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.QueryPageOptions; +import com.dbx.agent.QueryPageResult; +import com.dbx.agent.QueryResult; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public abstract class JdbcFakeExecutionBehaviorTest { + protected abstract DatabaseAgent createAgent(); + + protected abstract String resultSetSql(); + + @Test + protected void executesNonSelectStatementsThatReturnResultSets() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions()); + + assertEquals(Collections.singletonList("VALUE"), result.getColumns()); + assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows()); + assertEquals(asList("setMaxRows:10001", "execute"), JdbcAgentFake.calls); + } + + @Test + protected void appliesExecutionRowAndFetchLimitsToJdbcStatements() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions(1, 25)); + + assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows()); + assertEquals(asList("setMaxRows:2", "setFetchSize:25", "execute"), JdbcAgentFake.calls); + } + + @Test + protected void pagedQueryExecutionPassesCallerFetchSizeToStatement() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + agent.executeQueryPage(resultSetSql(), null, new QueryPageOptions(100, 500, 1000)); + + assertEquals(asList("setFetchSize:500", "execute"), JdbcAgentFake.calls); + } + + @Test + protected void tableReadPassesCallerFetchSizeToStatement() { + DatabaseAgent agent = createAgent(); + TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection()); + + agent.startTableRead(resultSetSql(), null, new QueryPageOptions(100, 500, 1000)); + + assertEquals(asList("setFetchSize:500", "execute"), JdbcAgentFake.calls); + } + + protected static java.util.List asList(String first, String second) { + return java.util.Arrays.asList(first, second); + } + + protected static java.util.List asList(String first, String second, String third) { + return java.util.Arrays.asList(first, second, third); + } +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java new file mode 100644 index 0000000..9ef0b26 --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java @@ -0,0 +1,67 @@ +package com.dbx.agent.test; + +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.TableInfo; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public abstract class JdbcMetadataBehaviorTest extends JdbcConnectedAgentTest { + protected abstract List metadataFixtureSql(); + + protected abstract String metadataSchema(); + + protected abstract List expectedTablesInOrder(); + + protected abstract String metadataColumnsTable(); + + protected abstract List expectedColumnsInOrder(); + + @Test + void returnsMetadataInStableOrder() { + withAgent("dbx-agent-metadata", agent -> { + for (String sql : metadataFixtureSql()) { + agent.executeQuery(sql, metadataSchema(), new com.dbx.agent.ExecuteQueryOptions()); + } + + List expectedTables = expectedTablesInOrder(); + List tableNames = new ArrayList(); + for (TableInfo table : agent.listTables(metadataSchema())) { + if (expectedTables.contains(table.getName())) { + tableNames.add(table.getName()); + } + } + assertEquals(expectedTables, tableNames); + + List expectedColumns = expectedColumnsInOrder(); + List columnNames = new ArrayList(); + for (ColumnInfo column : agent.getColumns(metadataSchema(), metadataColumnsTable())) { + if (expectedColumns.contains(column.getName())) { + columnNames.add(column.getName()); + } + } + assertEquals(expectedColumns, columnNames); + }); + } + + @Test + void buildsTableDdlFromMetadata() { + withAgent("dbx-agent-ddl", agent -> { + for (String sql : metadataFixtureSql()) { + agent.executeQuery(sql, metadataSchema(), new com.dbx.agent.ExecuteQueryOptions()); + } + + String ddl = agent.getTableDdl(metadataSchema(), metadataColumnsTable()); + + for (String column : expectedColumnsInOrder()) { + assertTrue(ddl.contains("\"" + column + "\""), "DDL should include column " + column + ": " + ddl); + } + assertTrue(ddl.contains("CREATE TABLE"), "DDL should include CREATE TABLE: " + ddl); + assertTrue(ddl.contains(metadataColumnsTable()), "DDL should include table name: " + ddl); + }); + } +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataSqlFake.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataSqlFake.java new file mode 100644 index 0000000..3482872 --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataSqlFake.java @@ -0,0 +1,172 @@ +package com.dbx.agent.test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +public final class JdbcMetadataSqlFake { + public static final JdbcMetadataSqlFake INSTANCE = new JdbcMetadataSqlFake(); + public static final List statements = new ArrayList(); + + private JdbcMetadataSqlFake() { + } + + public static Connection connection() { + statements.clear(); + return proxy(Connection.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("createStatement".equals(name)) { + return statement(); + } + if ("prepareStatement".equals(name)) { + statements.add((String) args[0]); + return preparedStatement(); + } + if ("getAutoCommit".equals(name)) { + return true; + } + if ("setAutoCommit".equals(name) || "commit".equals(name) || "rollback".equals(name) || "close".equals(name)) { + return null; + } + if ("isClosed".equals(name)) { + return false; + } + return defaultValue(method.getReturnType()); + } + }); + } + + public List getStatements() { + return statements; + } + + private static Statement statement() { + ResultSet resultSet = emptyResultSet(); + return proxy(Statement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("execute".equals(name) || "executeQuery".equals(name)) { + statements.add((String) args[0]); + return "execute".equals(name) ? false : resultSet; + } + if ("getResultSet".equals(name)) { + return resultSet; + } + if ("getUpdateCount".equals(name)) { + return 0; + } + if ("setMaxRows".equals(name) || "close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static PreparedStatement preparedStatement() { + ResultSet resultSet = emptyResultSet(); + return proxy(PreparedStatement.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("execute".equals(name) || "executeQuery".equals(name)) { + return "execute".equals(name) ? false : resultSet; + } + if ("getResultSet".equals(name)) { + return resultSet; + } + if ("getUpdateCount".equals(name)) { + return 0; + } + if ("setString".equals(name) || "setInt".equals(name) || "setObject".equals(name)) { + statements.add("param:" + args[0] + "=" + args[1]); + return null; + } + if ("setMaxRows".equals(name) || "close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static ResultSet emptyResultSet() { + ResultSetMetaData metadata = proxy(ResultSetMetaData.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + if ("getColumnCount".equals(method.getName())) { + return 0; + } + return defaultValue(method.getReturnType()); + } + }); + return proxy(ResultSet.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + return false; + } + if ("getMetaData".equals(name)) { + return metadata; + } + if ("close".equals(name)) { + return null; + } + return defaultValue(method.getReturnType()); + } + }); + } + + private static T proxy(Class type, final MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return handler.handle(method, args); + } + }; + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler)); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Byte.TYPE.equals(type)) { + return (byte) 0; + } + if (Short.TYPE.equals(type)) { + return (short) 0; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + if (Long.TYPE.equals(type)) { + return 0L; + } + if (Float.TYPE.equals(type)) { + return 0f; + } + if (Double.TYPE.equals(type)) { + return 0.0d; + } + if (Character.TYPE.equals(type)) { + return '\0'; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args); + } +} diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/TestSupport.java b/agents/test-support/src/main/java/com/dbx/agent/test/TestSupport.java new file mode 100644 index 0000000..aaddefa --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/test/TestSupport.java @@ -0,0 +1,31 @@ +package com.dbx.agent.test; + +import java.lang.reflect.Field; +import java.sql.Connection; + +public final class TestSupport { + private TestSupport() { + } + + public static void setPrivateConnection(Object target, Connection connection) { + Field field = findConnectionField(target.getClass()); + field.setAccessible(true); + try { + field.set(target, connection); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to set connection", e); + } + } + + private static Field findConnectionField(Class type) { + Class current = type; + while (current != null) { + try { + return current.getDeclaredField("connection"); + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new IllegalStateException(new NoSuchFieldException("connection")); + } +} diff --git a/agents/versions.json b/agents/versions.json new file mode 100644 index 0000000..960219b --- /dev/null +++ b/agents/versions.json @@ -0,0 +1,43 @@ +{ + "access": "0.1.27", + "bigquery": "0.1.31", + "cassandra": "0.1.30", + "dameng": "0.1.33", + "databend": "0.1.18", + "databricks": "0.1.25", + "db2": "0.1.30", + "exasol": "0.1.25", + "firebird": "0.1.25", + "gbase8a": "0.1.25", + "gbase8s": "0.1.19", + "goldendb": "0.1.30", + "h2": "0.1.31", + "h2-legacy": "0.1.0", + "hive": "0.1.30", + "spark": "0.1.2", + "informix": "0.1.31", + "kingbase": "0.1.31", + "kylin": "0.1.30", + "neo4j": "0.1.30", + "oceanbase-oracle": "0.1.25", + "oracle": "0.1.34", + "snowflake": "0.1.30", + "saphana": "0.1.25", + "sundb": "0.1.30", + "teradata": "0.1.25", + "tdengine": "0.1.29", + "trino": "0.1.30", + "vastbase": "0.1.30", + "vertica": "0.1.25", + "mongodb": "0.1.31", + "highgo": "0.1.28", + "yashandb": "0.1.26", + "oscar": "0.1.5", + "xugu": "0.1.18", + "iris": "0.1.21", + "iotdb": "0.1.18", + "etcd": "0.1.18", + "zookeeper": "0.1.8", + "kafka": "0.1.3", + "sqlserver-legacy": "0.1.1" +} diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..9b720a1 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,9 @@ +# Apps + +Runnable DBX applications live here. + +## Directories + +- `desktop/` - the Vue frontend used by the Tauri desktop app and the Docker/web build. + +The Tauri native shell remains in `src-tauri/` because that is the conventional Tauri project location used by the existing build and release tooling. diff --git a/apps/desktop/components.json b/apps/desktop/components.json new file mode 100644 index 0000000..d2f0350 --- /dev/null +++ b/apps/desktop/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "reka-nova", + "font": "geist-sans", + "typescript": true, + "tailwind": { + "config": "", + "css": "apps/desktop/src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/common/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "composables": "@/composables" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..a68bf9c --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,28 @@ + + + + + + + DBX + + +
+ +
+ + + diff --git a/apps/desktop/public/favicon.png b/apps/desktop/public/favicon.png new file mode 100644 index 0000000..407755f Binary files /dev/null and b/apps/desktop/public/favicon.png differ diff --git a/apps/desktop/public/fonts/Geist-LICENSE.txt b/apps/desktop/public/fonts/Geist-LICENSE.txt new file mode 100644 index 0000000..98835ac --- /dev/null +++ b/apps/desktop/public/fonts/Geist-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) Geist-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/desktop/public/fonts/geist-latin-ext-wght-normal.woff2 b/apps/desktop/public/fonts/geist-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..ba90e20 Binary files /dev/null and b/apps/desktop/public/fonts/geist-latin-ext-wght-normal.woff2 differ diff --git a/apps/desktop/public/fonts/geist-latin-wght-normal.woff2 b/apps/desktop/public/fonts/geist-latin-wght-normal.woff2 new file mode 100644 index 0000000..991445d Binary files /dev/null and b/apps/desktop/public/fonts/geist-latin-wght-normal.woff2 differ diff --git a/apps/desktop/public/icon-preview-black.png b/apps/desktop/public/icon-preview-black.png new file mode 100644 index 0000000..fec2510 Binary files /dev/null and b/apps/desktop/public/icon-preview-black.png differ diff --git a/apps/desktop/public/icon-preview-default.png b/apps/desktop/public/icon-preview-default.png new file mode 100644 index 0000000..e4ccc40 Binary files /dev/null and b/apps/desktop/public/icon-preview-default.png differ diff --git a/apps/desktop/public/icons/ai/alibabacloud.svg b/apps/desktop/public/icons/ai/alibabacloud.svg new file mode 100644 index 0000000..c1d8e4f --- /dev/null +++ b/apps/desktop/public/icons/ai/alibabacloud.svg @@ -0,0 +1 @@ +Alibaba Cloud \ No newline at end of file diff --git a/apps/desktop/public/icons/ai/anthropic.svg b/apps/desktop/public/icons/ai/anthropic.svg new file mode 100644 index 0000000..88dc745 --- /dev/null +++ b/apps/desktop/public/icons/ai/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/apps/desktop/public/icons/ai/codex.svg b/apps/desktop/public/icons/ai/codex.svg new file mode 100644 index 0000000..c77ccfd --- /dev/null +++ b/apps/desktop/public/icons/ai/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/apps/desktop/public/icons/ai/deepseek.svg b/apps/desktop/public/icons/ai/deepseek.svg new file mode 100644 index 0000000..185c108 --- /dev/null +++ b/apps/desktop/public/icons/ai/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/apps/desktop/public/icons/ai/googlegemini.svg b/apps/desktop/public/icons/ai/googlegemini.svg new file mode 100644 index 0000000..e15e53c --- /dev/null +++ b/apps/desktop/public/icons/ai/googlegemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/apps/desktop/public/icons/ai/ollama.svg b/apps/desktop/public/icons/ai/ollama.svg new file mode 100644 index 0000000..432f73e --- /dev/null +++ b/apps/desktop/public/icons/ai/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/apps/desktop/public/icons/ai/openai.svg b/apps/desktop/public/icons/ai/openai.svg new file mode 100644 index 0000000..2dd8be2 --- /dev/null +++ b/apps/desktop/public/icons/ai/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/access.png b/apps/desktop/public/icons/database/access.png new file mode 100644 index 0000000..44138a1 Binary files /dev/null and b/apps/desktop/public/icons/database/access.png differ diff --git a/apps/desktop/public/icons/database/apache_kylin.svg b/apps/desktop/public/icons/database/apache_kylin.svg new file mode 100644 index 0000000..7a15a5a --- /dev/null +++ b/apps/desktop/public/icons/database/apache_kylin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/bigquery.svg b/apps/desktop/public/icons/database/bigquery.svg new file mode 100644 index 0000000..a9a42f0 --- /dev/null +++ b/apps/desktop/public/icons/database/bigquery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/cassandra.svg b/apps/desktop/public/icons/database/cassandra.svg new file mode 100644 index 0000000..fbeedb1 --- /dev/null +++ b/apps/desktop/public/icons/database/cassandra.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/chromadb.svg b/apps/desktop/public/icons/database/chromadb.svg new file mode 100644 index 0000000..59f5f59 --- /dev/null +++ b/apps/desktop/public/icons/database/chromadb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/desktop/public/icons/database/clickhouse.svg b/apps/desktop/public/icons/database/clickhouse.svg new file mode 100644 index 0000000..7c0e072 --- /dev/null +++ b/apps/desktop/public/icons/database/clickhouse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/cloudflare-d1.svg b/apps/desktop/public/icons/database/cloudflare-d1.svg new file mode 100644 index 0000000..d3d97d3 --- /dev/null +++ b/apps/desktop/public/icons/database/cloudflare-d1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/public/icons/database/cockroachdb.svg b/apps/desktop/public/icons/database/cockroachdb.svg new file mode 100644 index 0000000..862e86f --- /dev/null +++ b/apps/desktop/public/icons/database/cockroachdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/databend.svg b/apps/desktop/public/icons/database/databend.svg new file mode 100644 index 0000000..ab3b974 --- /dev/null +++ b/apps/desktop/public/icons/database/databend.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/apps/desktop/public/icons/database/databricks.webp b/apps/desktop/public/icons/database/databricks.webp new file mode 100644 index 0000000..0a74319 Binary files /dev/null and b/apps/desktop/public/icons/database/databricks.webp differ diff --git a/apps/desktop/public/icons/database/db2.svg b/apps/desktop/public/icons/database/db2.svg new file mode 100644 index 0000000..728876a --- /dev/null +++ b/apps/desktop/public/icons/database/db2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/dm.svg b/apps/desktop/public/icons/database/dm.svg new file mode 100644 index 0000000..5315156 --- /dev/null +++ b/apps/desktop/public/icons/database/dm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/doris.svg b/apps/desktop/public/icons/database/doris.svg new file mode 100644 index 0000000..e18c60e --- /dev/null +++ b/apps/desktop/public/icons/database/doris.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/dremio.svg b/apps/desktop/public/icons/database/dremio.svg new file mode 100644 index 0000000..f209f46 --- /dev/null +++ b/apps/desktop/public/icons/database/dremio.svg @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/apps/desktop/public/icons/database/duckdb.svg b/apps/desktop/public/icons/database/duckdb.svg new file mode 100644 index 0000000..14dc392 --- /dev/null +++ b/apps/desktop/public/icons/database/duckdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/elasticsearch.svg b/apps/desktop/public/icons/database/elasticsearch.svg new file mode 100644 index 0000000..48ef8a3 --- /dev/null +++ b/apps/desktop/public/icons/database/elasticsearch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/etcd.svg b/apps/desktop/public/icons/database/etcd.svg new file mode 100644 index 0000000..a36ae07 --- /dev/null +++ b/apps/desktop/public/icons/database/etcd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/public/icons/database/exasol.webp b/apps/desktop/public/icons/database/exasol.webp new file mode 100644 index 0000000..00b340d Binary files /dev/null and b/apps/desktop/public/icons/database/exasol.webp differ diff --git a/apps/desktop/public/icons/database/firebird.webp b/apps/desktop/public/icons/database/firebird.webp new file mode 100644 index 0000000..fd84e6f Binary files /dev/null and b/apps/desktop/public/icons/database/firebird.webp differ diff --git a/apps/desktop/public/icons/database/gaussdb.svg b/apps/desktop/public/icons/database/gaussdb.svg new file mode 100644 index 0000000..f115a85 --- /dev/null +++ b/apps/desktop/public/icons/database/gaussdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/gbase.webp b/apps/desktop/public/icons/database/gbase.webp new file mode 100644 index 0000000..6a4bb56 Binary files /dev/null and b/apps/desktop/public/icons/database/gbase.webp differ diff --git a/apps/desktop/public/icons/database/goldendb.png b/apps/desktop/public/icons/database/goldendb.png new file mode 100644 index 0000000..b635a72 Binary files /dev/null and b/apps/desktop/public/icons/database/goldendb.png differ diff --git a/apps/desktop/public/icons/database/greatsql.webp b/apps/desktop/public/icons/database/greatsql.webp new file mode 100644 index 0000000..c7bd056 Binary files /dev/null and b/apps/desktop/public/icons/database/greatsql.webp differ diff --git a/apps/desktop/public/icons/database/h2.svg b/apps/desktop/public/icons/database/h2.svg new file mode 100644 index 0000000..56820c8 --- /dev/null +++ b/apps/desktop/public/icons/database/h2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/highgo.png b/apps/desktop/public/icons/database/highgo.png new file mode 100644 index 0000000..c1c434b Binary files /dev/null and b/apps/desktop/public/icons/database/highgo.png differ diff --git a/apps/desktop/public/icons/database/hive.svg b/apps/desktop/public/icons/database/hive.svg new file mode 100644 index 0000000..4c95973 --- /dev/null +++ b/apps/desktop/public/icons/database/hive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/influxdb.svg b/apps/desktop/public/icons/database/influxdb.svg new file mode 100644 index 0000000..18239a1 --- /dev/null +++ b/apps/desktop/public/icons/database/influxdb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/informix.svg b/apps/desktop/public/icons/database/informix.svg new file mode 100644 index 0000000..60426de --- /dev/null +++ b/apps/desktop/public/icons/database/informix.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/iotdb.svg b/apps/desktop/public/icons/database/iotdb.svg new file mode 100644 index 0000000..d742630 --- /dev/null +++ b/apps/desktop/public/icons/database/iotdb.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/public/icons/database/iris.png b/apps/desktop/public/icons/database/iris.png new file mode 100644 index 0000000..e4be5d9 Binary files /dev/null and b/apps/desktop/public/icons/database/iris.png differ diff --git a/apps/desktop/public/icons/database/kafka.svg b/apps/desktop/public/icons/database/kafka.svg new file mode 100644 index 0000000..a25ab74 --- /dev/null +++ b/apps/desktop/public/icons/database/kafka.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/public/icons/database/kingbase.svg b/apps/desktop/public/icons/database/kingbase.svg new file mode 100644 index 0000000..9d7f076 --- /dev/null +++ b/apps/desktop/public/icons/database/kingbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/kwdb.svg b/apps/desktop/public/icons/database/kwdb.svg new file mode 100644 index 0000000..4877759 --- /dev/null +++ b/apps/desktop/public/icons/database/kwdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/manticoresearch.png b/apps/desktop/public/icons/database/manticoresearch.png new file mode 100644 index 0000000..420cb72 Binary files /dev/null and b/apps/desktop/public/icons/database/manticoresearch.png differ diff --git a/apps/desktop/public/icons/database/mariadb.svg b/apps/desktop/public/icons/database/mariadb.svg new file mode 100644 index 0000000..3153880 --- /dev/null +++ b/apps/desktop/public/icons/database/mariadb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/milvus.png b/apps/desktop/public/icons/database/milvus.png new file mode 100644 index 0000000..4d3214c Binary files /dev/null and b/apps/desktop/public/icons/database/milvus.png differ diff --git a/apps/desktop/public/icons/database/mongodb.svg b/apps/desktop/public/icons/database/mongodb.svg new file mode 100644 index 0000000..ee47003 --- /dev/null +++ b/apps/desktop/public/icons/database/mongodb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/mysql.svg b/apps/desktop/public/icons/database/mysql.svg new file mode 100644 index 0000000..0a3adb7 --- /dev/null +++ b/apps/desktop/public/icons/database/mysql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/nacos.png b/apps/desktop/public/icons/database/nacos.png new file mode 100644 index 0000000..0d7626a Binary files /dev/null and b/apps/desktop/public/icons/database/nacos.png differ diff --git a/apps/desktop/public/icons/database/neo4j.svg b/apps/desktop/public/icons/database/neo4j.svg new file mode 100644 index 0000000..4f0e62f --- /dev/null +++ b/apps/desktop/public/icons/database/neo4j.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/oceanbase.svg b/apps/desktop/public/icons/database/oceanbase.svg new file mode 100644 index 0000000..2ace405 --- /dev/null +++ b/apps/desktop/public/icons/database/oceanbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/opengauss.svg b/apps/desktop/public/icons/database/opengauss.svg new file mode 100644 index 0000000..6f46971 --- /dev/null +++ b/apps/desktop/public/icons/database/opengauss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/oracle.svg b/apps/desktop/public/icons/database/oracle.svg new file mode 100644 index 0000000..20e98a4 --- /dev/null +++ b/apps/desktop/public/icons/database/oracle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/polardb.webp b/apps/desktop/public/icons/database/polardb.webp new file mode 100644 index 0000000..7363a49 Binary files /dev/null and b/apps/desktop/public/icons/database/polardb.webp differ diff --git a/apps/desktop/public/icons/database/postgres.svg b/apps/desktop/public/icons/database/postgres.svg new file mode 100644 index 0000000..a24c0a6 --- /dev/null +++ b/apps/desktop/public/icons/database/postgres.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/presto.svg b/apps/desktop/public/icons/database/presto.svg new file mode 100644 index 0000000..873883c --- /dev/null +++ b/apps/desktop/public/icons/database/presto.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/pulsar.svg b/apps/desktop/public/icons/database/pulsar.svg new file mode 100644 index 0000000..feca270 --- /dev/null +++ b/apps/desktop/public/icons/database/pulsar.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/public/icons/database/qdrant.svg b/apps/desktop/public/icons/database/qdrant.svg new file mode 100644 index 0000000..c10ad96 --- /dev/null +++ b/apps/desktop/public/icons/database/qdrant.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/questdb.svg b/apps/desktop/public/icons/database/questdb.svg new file mode 100644 index 0000000..48bff14 --- /dev/null +++ b/apps/desktop/public/icons/database/questdb.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/public/icons/database/redis.svg b/apps/desktop/public/icons/database/redis.svg new file mode 100644 index 0000000..2df1a4f --- /dev/null +++ b/apps/desktop/public/icons/database/redis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/redshift.svg b/apps/desktop/public/icons/database/redshift.svg new file mode 100644 index 0000000..83b1036 --- /dev/null +++ b/apps/desktop/public/icons/database/redshift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/rqlite.png b/apps/desktop/public/icons/database/rqlite.png new file mode 100644 index 0000000..a85d121 Binary files /dev/null and b/apps/desktop/public/icons/database/rqlite.png differ diff --git a/apps/desktop/public/icons/database/saphana.webp b/apps/desktop/public/icons/database/saphana.webp new file mode 100644 index 0000000..ca36c34 Binary files /dev/null and b/apps/desktop/public/icons/database/saphana.webp differ diff --git a/apps/desktop/public/icons/database/selectdb.svg b/apps/desktop/public/icons/database/selectdb.svg new file mode 100644 index 0000000..f19f626 --- /dev/null +++ b/apps/desktop/public/icons/database/selectdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/snowflake.svg b/apps/desktop/public/icons/database/snowflake.svg new file mode 100644 index 0000000..e986bee --- /dev/null +++ b/apps/desktop/public/icons/database/snowflake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/spark-logo.png b/apps/desktop/public/icons/database/spark-logo.png new file mode 100644 index 0000000..16702a9 Binary files /dev/null and b/apps/desktop/public/icons/database/spark-logo.png differ diff --git a/apps/desktop/public/icons/database/sqlite.svg b/apps/desktop/public/icons/database/sqlite.svg new file mode 100644 index 0000000..ebf14c3 --- /dev/null +++ b/apps/desktop/public/icons/database/sqlite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/sqlserver.svg b/apps/desktop/public/icons/database/sqlserver.svg new file mode 100644 index 0000000..360df61 --- /dev/null +++ b/apps/desktop/public/icons/database/sqlserver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/starrocks.svg b/apps/desktop/public/icons/database/starrocks.svg new file mode 100644 index 0000000..25d0351 --- /dev/null +++ b/apps/desktop/public/icons/database/starrocks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/sundb.svg b/apps/desktop/public/icons/database/sundb.svg new file mode 100644 index 0000000..8626ff8 --- /dev/null +++ b/apps/desktop/public/icons/database/sundb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/tdengine.svg b/apps/desktop/public/icons/database/tdengine.svg new file mode 100644 index 0000000..9d20ae0 --- /dev/null +++ b/apps/desktop/public/icons/database/tdengine.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/tdsql.webp b/apps/desktop/public/icons/database/tdsql.webp new file mode 100644 index 0000000..c6e6703 Binary files /dev/null and b/apps/desktop/public/icons/database/tdsql.webp differ diff --git a/apps/desktop/public/icons/database/teradata.webp b/apps/desktop/public/icons/database/teradata.webp new file mode 100644 index 0000000..cc35758 Binary files /dev/null and b/apps/desktop/public/icons/database/teradata.webp differ diff --git a/apps/desktop/public/icons/database/tidb.svg b/apps/desktop/public/icons/database/tidb.svg new file mode 100644 index 0000000..ee0b723 --- /dev/null +++ b/apps/desktop/public/icons/database/tidb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/trino.svg b/apps/desktop/public/icons/database/trino.svg new file mode 100644 index 0000000..725aaac --- /dev/null +++ b/apps/desktop/public/icons/database/trino.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/public/icons/database/turso.png b/apps/desktop/public/icons/database/turso.png new file mode 100644 index 0000000..d78843d Binary files /dev/null and b/apps/desktop/public/icons/database/turso.png differ diff --git a/apps/desktop/public/icons/database/vastbase.png b/apps/desktop/public/icons/database/vastbase.png new file mode 100644 index 0000000..1007b05 Binary files /dev/null and b/apps/desktop/public/icons/database/vastbase.png differ diff --git a/apps/desktop/public/icons/database/vertica.webp b/apps/desktop/public/icons/database/vertica.webp new file mode 100644 index 0000000..0ec01a3 Binary files /dev/null and b/apps/desktop/public/icons/database/vertica.webp differ diff --git a/apps/desktop/public/icons/database/weaviate.png b/apps/desktop/public/icons/database/weaviate.png new file mode 100644 index 0000000..8159f50 Binary files /dev/null and b/apps/desktop/public/icons/database/weaviate.png differ diff --git a/apps/desktop/public/icons/database/xugu.png b/apps/desktop/public/icons/database/xugu.png new file mode 100644 index 0000000..e564ac2 Binary files /dev/null and b/apps/desktop/public/icons/database/xugu.png differ diff --git a/apps/desktop/public/icons/database/yashandb.png b/apps/desktop/public/icons/database/yashandb.png new file mode 100644 index 0000000..ef3ab69 Binary files /dev/null and b/apps/desktop/public/icons/database/yashandb.png differ diff --git a/apps/desktop/public/icons/database/zookeeper.svg b/apps/desktop/public/icons/database/zookeeper.svg new file mode 100644 index 0000000..7d0da94 --- /dev/null +++ b/apps/desktop/public/icons/database/zookeeper.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/apps/desktop/public/logo-black.png b/apps/desktop/public/logo-black.png new file mode 100644 index 0000000..c016b38 Binary files /dev/null and b/apps/desktop/public/logo-black.png differ diff --git a/apps/desktop/public/logo.png b/apps/desktop/public/logo.png new file mode 100644 index 0000000..011a879 Binary files /dev/null and b/apps/desktop/public/logo.png differ diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue new file mode 100644 index 0000000..269eef2 --- /dev/null +++ b/apps/desktop/src/App.vue @@ -0,0 +1,2164 @@ + + + + + diff --git a/apps/desktop/src/components/admin/DamengJobAdmin.vue b/apps/desktop/src/components/admin/DamengJobAdmin.vue new file mode 100644 index 0000000..47a7184 --- /dev/null +++ b/apps/desktop/src/components/admin/DamengJobAdmin.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/apps/desktop/src/components/mq/SendMessagePanel.vue b/apps/desktop/src/components/mq/SendMessagePanel.vue new file mode 100644 index 0000000..2fae77e --- /dev/null +++ b/apps/desktop/src/components/mq/SendMessagePanel.vue @@ -0,0 +1,760 @@ + + + diff --git a/apps/desktop/src/components/quick-open/QuickOpenDialog.vue b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue new file mode 100644 index 0000000..e69d4b9 --- /dev/null +++ b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/apps/desktop/src/components/redis/RedisDashboard.vue b/apps/desktop/src/components/redis/RedisDashboard.vue new file mode 100644 index 0000000..ce67a27 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisDashboard.vue @@ -0,0 +1,480 @@ + + + diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue new file mode 100644 index 0000000..0c9ea00 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -0,0 +1,1473 @@ + + + + + diff --git a/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue new file mode 100644 index 0000000..d90bf4d --- /dev/null +++ b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue @@ -0,0 +1,230 @@ + + + diff --git a/apps/desktop/src/components/sidebar/VisibleSchemasDialog.vue b/apps/desktop/src/components/sidebar/VisibleSchemasDialog.vue new file mode 100644 index 0000000..367ee37 --- /dev/null +++ b/apps/desktop/src/components/sidebar/VisibleSchemasDialog.vue @@ -0,0 +1,235 @@ + + + diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue new file mode 100644 index 0000000..ab1bddd --- /dev/null +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue @@ -0,0 +1,621 @@ + + + diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue new file mode 100644 index 0000000..3c4f191 --- /dev/null +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -0,0 +1,3203 @@ + + +