feat(oracle): add TNS connections and trigger editing

This commit is contained in:
t8y2 committed 2026-07-23 23:03:45 +08:00
1 parent 9a5edae219
commit 3b004abb1e
23 files changed
+980 -53

No files matched your search

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "./oracleTnsConnection";
describe("Oracle TNS connection settings", () => {
it("round-trips an alias and TNS_ADMIN directory", () => {
const value = buildOracleTnsConnectionString("DBX_FAILOVER", "C:\\oracle network\\admin");
expect(parseOracleTnsConnectionString(value)).toEqual({
alias: "DBX_FAILOVER",
tnsAdmin: "C:\\oracle network\\admin",
});
});
it("normalizes a selected tnsnames.ora file to its parent directory", () => {
expect(normalizeOracleTnsAdminPath("C:\\oracle\\network\\admin\\tnsnames.ora")).toBe("C:\\oracle\\network\\admin");
expect(normalizeOracleTnsAdminPath("C:\\tnsnames.ora")).toBe("C:\\");
expect(normalizeOracleTnsAdminPath("/opt/oracle/network/admin/tnsnames.ora")).toBe("/opt/oracle/network/admin");
});
it("does not reinterpret service, SID, or descriptor JDBC URLs as TNS aliases", () => {
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1")).toBeNull();
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@db.example.com:1521:ORCL")).toBeNull();
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(HOST=db.example.com)))")).toBeNull();
});
it("ignores malformed encoded aliases instead of breaking connection editing", () => {
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@DBX%ZZ?TNS_ADMIN=%2Fopt%2Foracle")).toBeNull();
});
});
@@ -0,0 +1,43 @@
export interface OracleTnsConnectionConfig {
alias: string;
tnsAdmin: string;
}
const ORACLE_TNS_JDBC_PREFIX = "jdbc:oracle:thin:@";
export function parseOracleTnsConnectionString(value?: string): OracleTnsConnectionConfig | null {
const source = value?.trim() || "";
if (!source.toLowerCase().startsWith(ORACLE_TNS_JDBC_PREFIX)) return null;
const target = source.slice(ORACLE_TNS_JDBC_PREFIX.length);
if (!target || target.startsWith("(") || target.startsWith("//") || target.includes(":")) return null;
const [rawAlias, rawQuery = ""] = target.split("?", 2);
let alias = "";
try {
alias = decodeURIComponent(rawAlias).trim();
} catch {
return null;
}
const tnsAdmin = new URLSearchParams(rawQuery).get("TNS_ADMIN")?.trim() || "";
if (!alias) return null;
return { alias, tnsAdmin };
}
export function buildOracleTnsConnectionString(alias: string, tnsAdmin: string): string {
const normalizedAlias = alias.trim();
const normalizedTnsAdmin = normalizeOracleTnsAdminPath(tnsAdmin);
const query = new URLSearchParams({ TNS_ADMIN: normalizedTnsAdmin });
return `${ORACLE_TNS_JDBC_PREFIX}${encodeURIComponent(normalizedAlias)}?${query.toString()}`;
}
export function normalizeOracleTnsAdminPath(value: string): string {
const path = value.trim();
if (!path) return "";
if (!/(^|[\\/])tnsnames\.ora$/i.test(path)) return path;
const parent = path.replace(/[\\/]tnsnames\.ora$/i, "");
if (/^[A-Za-z]:$/.test(parent)) return `${parent}\\`;
if (parent) return parent;
return path.startsWith("\\") ? "\\" : "/";
}
@@ -2,6 +2,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureS
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate {
@@ -53,7 +54,9 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
}
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
const dialect = sqlSemanticDialectFor({ databaseType: input.databaseType });
// View-data SQL quotes resolved names, so fold only unquoted input exactly as the database does before quoting it again.
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => dialect.normalizeIdentifier(part.value, part.quoted));
if (parts.length === 0) return null;
const tableName = parts[parts.length - 1];