feat(oceanbase-oracle): support sequences, synonyms and db links

This commit is contained in:
efeire authored and GitHub committed 2026-09-20 00:49:44 +08:00
1 parent a19a4f595d
commit 04376ef3fb
31 files changed
+1225 -91

No files matched your search

@@ -20,16 +20,15 @@ describe("databaseObjectCapabilities", () => {
it("exposes synonyms only for database paths with synonym metadata", () => {
expect(sidebarObjectKindsForDatabase("oracle")).toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("xugu")).toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("oceanbase-oracle")).not.toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("oceanbase-oracle")).toContain("SYNONYM");
expect(sidebarObjectKindsForDatabase("postgres")).not.toContain("SYNONYM");
});
it("exposes OceanBase Oracle sequences without widening synonym support", () => {
it("exposes OceanBase Oracle sequences and synonyms through the grouped object path", () => {
const oceanBaseObjects = sidebarObjectKindsForDatabase("oceanbase-oracle");
expect(oceanBaseObjects).toEqual(["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]);
expect(databaseObjectCapabilities("oceanbase-oracle").sourceReadable).toEqual(["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]);
expect(oceanBaseObjects).not.toContain("SYNONYM");
expect(oceanBaseObjects).toEqual(["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]);
expect(databaseObjectCapabilities("oceanbase-oracle").sourceReadable).toEqual(["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]);
expect(
buildObjectGroupPlaceholderNodes({
nodeId: "connection:database:APP",
@@ -38,7 +37,7 @@ describe("databaseObjectCapabilities", () => {
schema: "APP",
objectTypes: oceanBaseObjects,
}).map((node) => node.type),
).toEqual(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]);
).toEqual(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-synonyms", "group-packages"]);
});
it("exposes Oracle sequences through the existing grouped object path", () => {
@@ -1,6 +1,57 @@
import { describe, expect, it } from "vitest";
import { ORACLE_DATABASE_LINKS_SQL, createOracleDatabaseLinkSql, alterOracleDatabaseLinkSql, dropOracleDatabaseLinkSql, oracleDatabaseLinkName } from "@/lib/database/oracleDatabaseLinks";
import {
OCEANBASE_ORACLE_DATABASE_LINKS_SQL,
ORACLE_DATABASE_LINKS_SQL,
createOracleDatabaseLinkSql,
createOceanBaseDatabaseLinkSql,
supportsOracleDatabaseLinks,
alterOracleDatabaseLinkSql,
dropOracleDatabaseLinkSql,
oracleDatabaseLinkName,
oracleDatabaseLinksSql,
redactOracleDatabaseLinkError,
testOracleDatabaseLinkSql,
} from "@/lib/database/oracleDatabaseLinks";
import { databaseObjectCapabilities } from "@/lib/database/databaseObjectCapabilities";
import { oracleDatabaseLinkCompletionContext as context, oracleDatabaseLinkCompletionItems as items } from "@/lib/sql/oracleDatabaseLinkCompletion";
describe("OceanBase Oracle schema objects", () => {
it("exposes sequence and synonym source through the same object model as Oracle", () => {
for (const db of ["oracle", "oceanbase-oracle"] as const) {
const capabilities = databaseObjectCapabilities(db);
expect(capabilities.sidebarObjects).toEqual(expect.arrayContaining(["SEQUENCE", "SYNONYM"]));
expect(capabilities.sourceReadable).toEqual(expect.arrayContaining(["SEQUENCE", "SYNONYM"]));
expect(supportsOracleDatabaseLinks(db)).toBe(true);
}
expect(supportsOracleDatabaseLinks("mysql")).toBe(false);
expect(supportsOracleDatabaseLinks(undefined)).toBe(false);
});
const input = { name: "REMOTE.EXAMPLE", public: false, username: "remote_user", tenant: "MixedTenant", password: "test-only@pass", host: "127.0.0.1:2881", protocol: "OB" as const };
it("creates OB links using HOST and preserves tenant and cluster case", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, cluster: "MixedCluster" })).toBe('CREATE DATABASE LINK REMOTE.EXAMPLE CONNECT TO "REMOTE_USER"@"MixedTenant" IDENTIFIED BY "test-only@pass" OB HOST \'127.0.0.1:2881\' CLUSTER "MixedCluster"');
});
it("generates the explicit PUBLIC form when selected", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, public: true })).toBe('CREATE PUBLIC DATABASE LINK REMOTE.EXAMPLE CONNECT TO "REMOTE_USER"@"MixedTenant" IDENTIFIED BY "test-only@pass" OB HOST \'127.0.0.1:2881\'');
});
it("creates OCI links using the oracle tenant and preserves quoted remote users", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, username: '"MixedUser"', protocol: "OCI", host: "127.0.0.1:1521/service" })).toBe('CREATE DATABASE LINK REMOTE.EXAMPLE CONNECT TO "MixedUser"@"oracle" IDENTIFIED BY "test-only@pass" OCI HOST \'127.0.0.1:1521/service\'');
});
it("escapes host literals rather than accepting SQL syntax", () => {
expect(createOceanBaseDatabaseLinkSql({ ...input, host: "bad'host" })).toContain("HOST 'bad''host'");
});
it("redacts complete and truncated passwords echoed by database errors", () => {
expect(redactOracleDatabaseLinkError('failed: IDENTIFIED BY "test-only@pass" OB HOST', "test-only@pass")).not.toContain("test-only@pass");
expect(redactOracleDatabaseLinkError('failed near IDENTIFIED BY "test-on', "test-only@pass")).toBe('failed near IDENTIFIED BY "[redacted]"');
});
it.each([{ tenant: "" }, { tenant: "\n" }, { password: "" }, { password: 'p"ass' }, { password: "p\nass" }, { host: "" }, { host: "bad\0host" }, { name: "x;DROP TABLE T" }, { protocol: "OCI", cluster: "c" }, { username: "" }])("rejects malformed link inputs %j", (overrides) => {
expect(() => createOceanBaseDatabaseLinkSql({ ...input, ...overrides } as Parameters<typeof createOceanBaseDatabaseLinkSql>[0])).toThrow();
});
it("enables the shared @link completion only in supported contexts", () => {
const sql = "SELECT * FROM DUAL@REM";
expect(context(sql, sql.length, "oceanbase-oracle")).toEqual({ prefix: "REM", from: sql.indexOf("REM"), to: sql.length });
for (const text of ["SELECT 'DUAL@", "-- DUAL@", "SELECT @@"]) expect(context(text, text.length, "oceanbase-oracle")).toBeNull();
});
});
const privateLink = { owner: "APP", name: "REMOTE.EXAMPLE.COM", username: "REMOTE_USER", host: "//localhost:1521/XE", created: "" };
describe("Oracle database links", () => {
it.each(["SELECT * FROM DUAL@", "SELECT * FROM APP.DUAL@", 'SELECT * FROM "Odd table"@', "UPDATE APP.T@", "BEGIN APP.PROC@", "INSERT INTO T@", "DELETE FROM T@", "SELECT * FROM A JOIN B@"])("offers suffix completion in %s", (sql) => {
@@ -27,6 +78,17 @@ describe("Oracle database links", () => {
expect(ORACLE_DATABASE_LINKS_SQL).toContain("'PUBLIC'");
expect(ORACLE_DATABASE_LINKS_SQL).not.toContain("CURRENT_SCHEMA");
});
it("lists tenant-visible OceanBase links and marks links created by the session user", () => {
expect(oracleDatabaseLinksSql("oceanbase-oracle")).toBe(OCEANBASE_ORACLE_DATABASE_LINKS_SQL);
expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("FROM ALL_DB_LINKS");
expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("FROM USER_DB_LINKS");
expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("DBX_CAN_DROP");
expect(oracleDatabaseLinksSql("oracle")).toBe(ORACLE_DATABASE_LINKS_SQL);
});
it("tests OceanBase links through a remote catalog table instead of DUAL", () => {
expect(testOracleDatabaseLinkSql(privateLink, "oceanbase-oracle")).toBe("SELECT 1 AS DBX_LINK_OK FROM SYS.ALL_USERS@REMOTE.EXAMPLE.COM WHERE ROWNUM = 1");
expect(testOracleDatabaseLinkSql(privateLink, "oracle")).toBe("SELECT 1 AS DBX_LINK_OK FROM DUAL@REMOTE.EXAMPLE.COM");
});
it("creates fixed-user links with an escaped connect string", () => {
expect(createOracleDatabaseLinkSql({ name: privateLink.name, username: "REMOTE_USER", password: "p@ss", host: "test'host", public: false })).toBe(`CREATE DATABASE LINK REMOTE.EXAMPLE.COM CONNECT TO "REMOTE_USER" IDENTIFIED BY "p@ss" USING 'test''host'`);
});
@@ -34,5 +96,6 @@ describe("Oracle database links", () => {
it("preserves a case-sensitive remote user read from metadata", () => expect(alterOracleDatabaseLinkSql({ ...privateLink, username: "MixedCase" }, "new-pass")).toContain('CONNECT TO "MixedCase"'));
it("normalizes unquoted usernames when creating links", () => expect(createOracleDatabaseLinkSql({ name: "REMOTE", username: "remote_user", password: "pass", host: "XE", public: false })).toContain('CONNECT TO "REMOTE_USER"'));
it("drops public links with the public modifier", () => expect(dropOracleDatabaseLinkSql({ ...privateLink, owner: "PUBLIC" })).toBe("DROP PUBLIC DATABASE LINK REMOTE.EXAMPLE.COM"));
it("drops OceanBase public links with the syntax accepted by OceanBase 4.2.5", () => expect(dropOracleDatabaseLinkSql({ ...privateLink, owner: "PUBLIC" }, "oceanbase-oracle")).toBe("DROP DATABASE LINK REMOTE.EXAMPLE.COM"));
it.each(["x; DROP TABLE t", "x--", 'x"', "a b", "a..b"])("rejects unsafe link names %s", (name) => expect(() => oracleDatabaseLinkName(name)).toThrow());
});
@@ -33,7 +33,7 @@ const VASTBASE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VI
const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"];
const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"];
const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"];
const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"];
const PACKAGE_MEMBER_EXPANSION_DATABASES = new Set<DatabaseType>(["oracle", "xugu"]);
@@ -1,4 +1,8 @@
import type { QueryResult } from "@/types/database";
import type { DatabaseType, QueryResult } from "@/types/database";
export function supportsOracleDatabaseLinks(databaseType?: DatabaseType): boolean {
return databaseType === "oracle" || databaseType === "oceanbase-oracle";
}
export interface OracleDatabaseLink {
name: string;
@@ -6,6 +10,7 @@ export interface OracleDatabaseLink {
username: string;
host: string;
created: string;
canDrop?: boolean;
}
// Links belong to the login user, independently of ALTER SESSION SET CURRENT_SCHEMA.
@@ -16,12 +21,33 @@ FROM ALL_DB_LINKS
WHERE OWNER IN (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'PUBLIC')
ORDER BY DB_LINK, CASE WHEN OWNER = 'PUBLIC' THEN 1 ELSE 0 END`;
// OceanBase 4.2.5 links created without a PUBLIC keyword are tenant-visible
// and ALL_DB_LINKS reports them as PUBLIC. USER_DB_LINKS identifies links
// created by the login user, which are the only links that user can drop.
export const OCEANBASE_ORACLE_DATABASE_LINKS_SQL = `SELECT L.OWNER, L.DB_LINK, L.USERNAME, L.HOST,
TO_CHAR(L.CREATED, 'YYYY-MM-DD HH24:MI:SS') AS CREATED,
CASE WHEN EXISTS (SELECT 1 FROM USER_DB_LINKS U WHERE U.DB_LINK = L.DB_LINK) THEN 1 ELSE 0 END AS DBX_CAN_DROP
FROM ALL_DB_LINKS L
ORDER BY L.DB_LINK`;
export function oracleDatabaseLinksSql(databaseType?: DatabaseType): string {
return databaseType === "oceanbase-oracle" ? OCEANBASE_ORACLE_DATABASE_LINKS_SQL : ORACLE_DATABASE_LINKS_SQL;
}
export function oracleDatabaseLinksFromResult(result: QueryResult): OracleDatabaseLink[] {
const indexes = new Map(result.columns.map((name, index) => [name.toUpperCase(), index]));
const canDropIndex = indexes.get("DBX_CAN_DROP");
return result.rows
.map((row) => {
const value = (name: string) => String(row[indexes.get(name) ?? -1] ?? "");
return { name: value("DB_LINK"), owner: value("OWNER"), username: value("USERNAME"), host: value("HOST"), created: value("CREATED") };
return {
name: value("DB_LINK"),
owner: value("OWNER"),
username: value("USERNAME"),
host: value("HOST"),
created: value("CREATED"),
canDrop: canDropIndex === undefined || String(row[canDropIndex]) === "1",
};
})
.filter((link) => !!link.name && !!link.owner);
}
@@ -33,25 +59,53 @@ export function oracleDatabaseLinkName(name: string): string {
return name;
}
export function redactOracleDatabaseLinkError(error: unknown, secret: string): string {
let message = String(error);
if (secret) message = message.replaceAll(secret, "[redacted]");
// OceanBase may truncate the echoed SQL in the middle of a password, so
// replacing only the complete input is insufficient.
return message.replace(/(IDENTIFIED\s+BY\s+")[^"]*(?:"|$)/gi, '$1[redacted]"');
}
function oracleQuotedIdentifier(value: string, fromMetadata = false): string {
if (!value || /[\0\r\n]/.test(value)) throw new Error("Invalid identifier");
if (!value || value.includes("\0") || value.includes("\r") || value.includes("\n")) throw new Error("Invalid identifier");
const name = fromMetadata ? value : value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replaceAll('""', '"') : value.toUpperCase();
return `"${name.replaceAll('"', '""')}"`;
}
export function createOracleDatabaseLinkSql(input: { name: string; public: boolean; username: string; password: string; host: string }): string {
if (!input.host.trim() || !input.password || /[\0\r\n"]/.test(input.password)) throw new Error("A connect string and a password without double quotes or line breaks are required");
if (!input.host.trim() || !input.password || input.password.includes("\0") || input.password.includes("\r") || input.password.includes("\n") || input.password.includes('"')) {
throw new Error("A connect string and a password without double quotes or line breaks are required");
}
return `CREATE ${input.public ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(input.name)} CONNECT TO ${oracleQuotedIdentifier(input.username)} IDENTIFIED BY "${input.password}" USING '${input.host.replaceAll("'", "''")}'`;
}
export function createOceanBaseDatabaseLinkSql(input: { name: string; public: boolean; username: string; tenant: string; password: string; host: string; protocol: "OB" | "OCI"; cluster?: string }): string {
if (!["OB", "OCI"].includes(input.protocol)) throw new Error("Unsupported database link protocol");
if (!input.password || input.password.includes("\0") || input.password.includes("\r") || input.password.includes("\n") || input.password.includes('"') || !input.host.trim() || input.host.includes("\0") || input.host.includes("\r") || input.host.includes("\n")) {
throw new Error("A host and a password without double quotes or line breaks are required");
}
const tenant = input.protocol === "OCI" ? "oracle" : input.tenant.trim();
const cluster = input.cluster?.trim();
if (input.protocol === "OCI" && cluster) throw new Error("Cluster is only supported for OceanBase links");
// Tenant/cluster names are case-sensitive configuration, not unquoted SQL identifiers.
// 4.2.5 rejects the Oracle-compatible PUBLIC keyword, while links created
// with this syntax are visible tenant-wide and reported as OWNER=PUBLIC.
return `CREATE ${input.public ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(input.name)} CONNECT TO ${oracleQuotedIdentifier(input.username)}@${oracleQuotedIdentifier(tenant, true)} IDENTIFIED BY "${input.password}" ${input.protocol} HOST '${input.host.replaceAll("'", "''")}'${cluster ? ` CLUSTER ${oracleQuotedIdentifier(cluster, true)}` : ""}`;
}
export function alterOracleDatabaseLinkSql(link: OracleDatabaseLink, password: string): string {
if (!link.username || !password || /[\0\r\n"]/.test(password)) throw new Error("A fixed-user link and a password without double quotes or line breaks are required");
if (!link.username || !password || password.includes("\0") || password.includes("\r") || password.includes("\n") || password.includes('"')) {
throw new Error("A fixed-user link and a password without double quotes or line breaks are required");
}
return `ALTER ${link.owner === "PUBLIC" ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(link.name)} CONNECT TO ${oracleQuotedIdentifier(link.username, true)} IDENTIFIED BY "${password}"`;
}
export function dropOracleDatabaseLinkSql(link: OracleDatabaseLink): string {
return `DROP ${link.owner === "PUBLIC" ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(link.name)}`;
export function dropOracleDatabaseLinkSql(link: OracleDatabaseLink, databaseType?: DatabaseType): string {
const publicKeyword = databaseType !== "oceanbase-oracle" && link.owner === "PUBLIC" ? "PUBLIC " : "";
return `DROP ${publicKeyword}DATABASE LINK ${oracleDatabaseLinkName(link.name)}`;
}
export function testOracleDatabaseLinkSql(link: OracleDatabaseLink): string {
return `SELECT 1 AS DBX_LINK_OK FROM DUAL@${oracleDatabaseLinkName(link.name)}`;
export function testOracleDatabaseLinkSql(link: OracleDatabaseLink, databaseType?: DatabaseType): string {
const name = oracleDatabaseLinkName(link.name);
return databaseType === "oceanbase-oracle" ? `SELECT 1 AS DBX_LINK_OK FROM SYS.ALL_USERS@${name} WHERE ROWNUM = 1` : `SELECT 1 AS DBX_LINK_OK FROM DUAL@${name}`;
}
@@ -1,9 +1,9 @@
import type { DatabaseType } from "@/types/database";
import type { OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
import { supportsOracleDatabaseLinks, type OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
import { isSqlCompletionSuppressedContext } from "@/lib/sql/sqlCompletion";
export function oracleDatabaseLinkCompletionContext(sql: string, cursor: number, databaseType?: DatabaseType) {
if (databaseType !== "oracle" || isSqlCompletionSuppressedContext(sql, cursor, { databaseType })) return null;
if (!supportsOracleDatabaseLinks(databaseType) || isSqlCompletionSuppressedContext(sql, cursor, { databaseType })) return null;
const before = sql.slice(0, cursor);
const match = /(?:[A-Za-z0-9_$#]|"(?:[^"]|"")+"|\))@([A-Za-z0-9_$#.]*)$/.exec(before);
if (!match) return null;