fix(phoenix): preserve schema in all table references

This commit is contained in:
jearhe authored and GitHub committed 2026-08-21 10:18:56 +08:00
1 parent e139f63953
commit f2e13922a2
18 files changed
+200 -9

No files matched your search

+1
View File
@@ -1686,6 +1686,7 @@ async function newQuery() {
targetConnectionId: target.connectionId,
targetDatabase: target.database,
databaseType: effectiveDatabaseTypeForConnection(conn),
driverProfile: conn.driver_profile,
identifierQuote: connectionStore.connectionIdentifierQuote?.(target.connectionId),
});
const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema, initialSql, target.catalog);
@@ -1719,7 +1719,7 @@ async function loadTemplateContext(allowView = false) {
}
const identifierQuote = connectionStore.connectionIdentifierQuote(node.connectionId);
return { node, dbType, identifierQuote, tableSchema, columns, tableType };
return { node, dbType, driverProfile: config?.driver_profile, identifierQuote, tableSchema, columns, tableType };
}
function openSqlTemplateTab(connectionId: string, database: string, schema: string | undefined, catalog: string | undefined, sql: string, title?: string) {
@@ -1733,6 +1733,7 @@ async function newSelectTemplate() {
if (!context) return;
const sql = buildTableSelectTemplate({
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
@@ -1752,6 +1753,7 @@ async function newInsertTemplate() {
if (!context) return;
const sql = buildTableInsertTemplate({
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
@@ -1772,6 +1774,7 @@ async function newUpdateTemplate() {
if (!context) return;
const sql = buildTableUpdateTemplate({
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
@@ -1791,6 +1794,7 @@ async function newDeleteTemplate() {
if (!context) return;
const sql = buildTableDeleteTemplate({
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
@@ -208,6 +208,10 @@ function currentDatabaseType(): DatabaseType | undefined {
return activeNode.value.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(activeNode.value.connectionId)) : undefined;
}
function currentDriverProfile(): string | undefined {
return activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId)?.driver_profile : undefined;
}
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
switch (node.type) {
case "connection":
@@ -1116,6 +1120,7 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
database: activeNode.value.database,
referenceType: "database",
databaseType: currentDatabaseType(),
driverProfile: currentDriverProfile(),
});
}
if (activeNode.value.type === "column") {
@@ -1128,6 +1133,7 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
tableName: activeNode.value.tableName,
columnName,
databaseType: currentDatabaseType(),
driverProfile: currentDriverProfile(),
});
}
const payload = createTableReferencePayload({
@@ -1136,6 +1142,7 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
schema: activeNode.value.schema,
tableName: activeNode.value.label,
databaseType: currentDatabaseType(),
driverProfile: currentDriverProfile(),
});
return payload;
}
@@ -359,6 +359,31 @@ describe("useSidebarDataOpenRuntime", () => {
});
});
it("preserves JDBC table schema through the table-data request", async () => {
mocks.databaseType = "jdbc";
mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({
metadata: {
schema: request.schema,
tableName: request.tableName,
tableType: request.tableType,
database: request.database,
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
indexes: [],
primaryKeys: ["id"],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
}));
await useSidebarDataOpenRuntime().openData(tableNode);
await vi.waitFor(() => expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["id"]));
expect(mocks.loadTableMetadata).toHaveBeenCalledWith(expect.objectContaining({ database: "app", schema: "public" }));
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ database: "app", schema: "public", tableName: "users" }));
expect(mocks.tabs[0]?.tableMeta).toMatchObject({ database: "app", schema: "public", tableName: "users" });
});
it("keeps MySQL data-tab identity unqualified after metadata loads", async () => {
mocks.databaseType = "mysql";
mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({
@@ -73,6 +73,17 @@ describe("jdbc dialect inference", () => {
).toBe("sqlserver");
});
it("keeps Phoenix as generic JDBC while preserving its schema tree", () => {
const connection = { db_type: "jdbc" as const, driver_profile: "phoenix" };
expect(inferJdbcDialect(connection)).toBe("jdbc");
expect(effectiveDatabaseTypeForConnection(connection)).toBe("jdbc");
expect(connectionUsesDatabaseObjectTreeMode(connection)).toBe(false);
expect(connectionObjectTreeQuerySchema(connection, "default", "DEMO")).toBe("DEMO");
expect(connectionObjectTreeNodeSchema(connection, "default", "DEMO")).toBe("DEMO");
expect(connectionShouldLoadIdentifierQuote(connection)).toBe(true);
});
it.each([
["jdbc:oracle:thin:@//localhost:1521/XE", "oracle"],
["jdbc:dm://localhost:5236/DAMENG", "dameng"],
@@ -14,6 +14,20 @@ describe("query editor table reference drop", () => {
expect(tableReferenceInsertText(payload!)).toBe("`app-db`");
});
it("preserves the Phoenix schema in dragged table references", () => {
const payload = createTableReferencePayload({
connectionId: "conn-1",
database: "default",
schema: "APP",
tableName: "USERS",
databaseType: "jdbc",
driverProfile: "phoenix",
})!;
expect(parseTableReferencePayload(JSON.stringify(payload))).toEqual(payload);
expect(tableReferenceInsertText(payload)).toBe("APP.USERS");
});
it("round-trips database reference payloads", () => {
const payload = createTableReferencePayload({
connectionId: "conn-1",
@@ -204,6 +204,10 @@ describe("buildSelectAllSql", () => {
expect(buildSelectAllSql("postgres", { schema: "public", tableName: "users" })).toBe('SELECT * FROM "public"."users"');
});
it("preserves the Phoenix schema for new-query prefill", () => {
expect(buildSelectAllSql("jdbc", { schema: "APP", tableName: "USERS" }, '"', "phoenix")).toBe('SELECT * FROM "APP"."USERS"');
});
it("bracket-quotes a SQL Server table", () => {
expect(buildSelectAllSql("sqlserver", { schema: "dbo", tableName: "users" })).toBe("SELECT * FROM [dbo].[users]");
expect(buildSelectAllSql("sqlserver", { tableName: "users" })).toBe("SELECT * FROM [users]");
@@ -258,6 +262,20 @@ describe("resolveNewQueryInitialSql", () => {
).toBe('SELECT * FROM "public"."users"');
});
it("passes the Phoenix driver profile into the initial SQL builder", () => {
expect(
resolveNewQueryInitialSql({
activeTab: dataTab({ schema: "APP", tableMeta: { schema: "APP", tableName: "USERS", columns: [], primaryKeys: [] } }),
prefillEnabled: true,
targetConnectionId: "conn-1",
targetDatabase: "app_db",
databaseType: "jdbc",
driverProfile: "phoenix",
identifierQuote: '"',
}),
).toBe('SELECT * FROM "APP"."USERS"');
});
it("leaves new queries empty when the setting is disabled", () => {
expect(
resolveNewQueryInitialSql({
@@ -34,6 +34,20 @@ describe("qualifiedTableName — SQLite attached databases", () => {
});
});
describe("qualifiedTableName — schema-aware JDBC profiles", () => {
it("qualifies Phoenix tables without forcing identifier quotes", () => {
expect(qualifiedTableName({ databaseType: "jdbc", driverProfile: "phoenix", schema: "DEMO", tableName: "STUDENT" })).toBe("DEMO.STUDENT");
});
it("uses the driver-reported quote for Phoenix special identifiers", () => {
expect(qualifiedTableName({ databaseType: "jdbc", driverProfile: "phoenix", identifierQuote: '"', schema: "MY_SCHEMA", tableName: "ORDER" })).toBe('"MY_SCHEMA"."ORDER"');
});
it("keeps an unscoped JDBC table unqualified", () => {
expect(qualifiedTableName({ databaseType: "jdbc", driverProfile: "phoenix", tableName: "STUDENT" })).toBe("STUDENT");
});
});
describe("qualifiedTableName — GBase 8s", () => {
it("omits the metadata owner for GBase 8s table data", () => {
expect(qualifiedTableName({ databaseType: "informix", driverProfile: "gbase8s", identifierQuote: "", schema: "gbasedbt", tableName: "connection_smoke" })).toBe("connection_smoke");
+9 -1
View File
@@ -21,6 +21,9 @@ const CONNECTION_ROOT_SCHEMA_TYPES = new Set<DatabaseType>(["oracle", "dameng",
const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = [
{ type: "databend", patterns: [/jdbc:databend:/i, /com\.databend\.jdbc\.DatabendDriver/i, /databend-jdbc/i] },
{ type: "starrocks", patterns: [/starrocks/i] },
// Phoenix remains a generic JDBC connection, but exposes queryable schemas.
// Returning `jdbc` keeps its generic SQL behavior while enabling the schema tree.
{ type: "jdbc", patterns: [/phoenix/i] },
{ type: "doris", patterns: [/doris/i] },
{ type: "goldendb", patterns: [/jdbc:goldendb:/i, /goldendb/i] },
{ type: "mysql", patterns: [/kyuubi/i] },
@@ -54,6 +57,10 @@ export function inferJdbcDialect(connection?: JdbcDialectConnection): DatabaseTy
return JDBC_DIALECT_MATCHERS.find((matcher) => matcher.patterns.some((pattern) => pattern.test(haystack)))?.type;
}
export function jdbcDriverProfileUsesSchemaQualification(driverProfile?: string): boolean {
return inferJdbcDialect({ db_type: "jdbc", driver_profile: driverProfile }) === "jdbc";
}
export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConnection): DatabaseType | undefined {
if (!connection) return undefined;
if (connection.db_type === "gbase" && isGbase8sProfile(connection.driver_profile)) return "informix";
@@ -90,7 +97,8 @@ export function connectionShouldLoadIdentifierQuote(connection: JdbcDialectConne
if (gaussdbIdentifierQuoteStyle(connection) !== "auto") return false;
if (connection.db_type === "gaussdb") return true;
if (connection.db_type !== "jdbc") return false;
return ["gaussdb", "opengauss", "postgres"].includes(inferJdbcDialect(connection) ?? "");
const dialect = inferJdbcDialect(connection);
return dialect === "jdbc" || ["gaussdb", "opengauss", "postgres"].includes(dialect ?? "");
}
export function supportsGaussdbIdentifierQuoteStyle(connection: JdbcDialectConnection | undefined): boolean {
@@ -13,6 +13,7 @@ export interface QueryEditorTableReferencePayload {
columnName?: string;
referenceType?: "database" | "table" | "column";
databaseType?: DatabaseType;
driverProfile?: string;
}
export interface QueryEditorTableReferenceDropDetail {
@@ -23,7 +24,7 @@ export interface QueryEditorTableReferenceDropDetail {
let activeTableReferencePayload: QueryEditorTableReferencePayload | null = null;
export function createTableReferencePayload(options: { connectionId?: string; database?: string; schema?: string; tableName?: string; columnName?: string; referenceType?: "database" | "table" | "column"; databaseType?: DatabaseType }): QueryEditorTableReferencePayload | null {
export function createTableReferencePayload(options: { connectionId?: string; database?: string; schema?: string; tableName?: string; columnName?: string; referenceType?: "database" | "table" | "column"; databaseType?: DatabaseType; driverProfile?: string }): QueryEditorTableReferencePayload | null {
if (!options.connectionId || options.database == null) return null;
const referenceType = options.referenceType ?? (options.columnName ? "column" : "table");
if (referenceType !== "database" && !options.tableName) return null;
@@ -43,6 +44,7 @@ export function createTableReferencePayload(options: { connectionId?: string; da
}
if (options.schema) payload.schema = options.schema;
if (options.databaseType) payload.databaseType = options.databaseType;
if (options.driverProfile) payload.driverProfile = options.driverProfile;
return payload;
}
@@ -65,6 +67,7 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
referenceType: "database",
};
if (parsed.databaseType) payload.databaseType = parsed.databaseType;
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
return payload;
}
if (typeof parsed.tableName !== "string" || !parsed.tableName) return null;
@@ -83,6 +86,7 @@ export function parseTableReferencePayload(value: string | undefined | null): Qu
}
if (typeof parsed.schema === "string" && parsed.schema) payload.schema = parsed.schema;
if (parsed.databaseType) payload.databaseType = parsed.databaseType;
if (typeof parsed.driverProfile === "string" && parsed.driverProfile) payload.driverProfile = parsed.driverProfile;
return payload;
} catch {
return null;
@@ -126,6 +130,7 @@ export function tableReferenceInsertText(payload: QueryEditorTableReferencePaylo
const tableName = payload.tableName || payload.database;
return qualifiedTableName({
databaseType,
driverProfile: payload.driverProfile,
schema: payload.schema,
tableName,
});
+4 -3
View File
@@ -90,6 +90,7 @@ export interface ResolveNewQueryInitialSqlInput extends ResolveNewQueryTableInpu
targetConnectionId: string;
targetDatabase: string;
databaseType?: DatabaseType;
driverProfile?: string;
identifierQuote?: string;
}
@@ -146,9 +147,9 @@ export function resolveNewQueryTable(input: ResolveNewQueryTableInput): NewQuery
* the same per-dialect identifier quoting and schema/catalog qualification used
* by the table-data view.
*/
export function buildSelectAllSql(databaseType: DatabaseType | undefined, table: Pick<NewQueryTable, "schema" | "catalog" | "tableName"> & Partial<Pick<NewQueryTable, "database">>, identifierQuote?: string): string {
export function buildSelectAllSql(databaseType: DatabaseType | undefined, table: Pick<NewQueryTable, "schema" | "catalog" | "tableName"> & Partial<Pick<NewQueryTable, "database">>, identifierQuote?: string, driverProfile?: string): string {
if (databaseType === "victoriametrics") return metricRangeQuery(table.tableName);
const ref = qualifiedTableName({ databaseType, identifierQuote, database: table.database, schema: table.schema, catalog: table.catalog, tableName: table.tableName });
const ref = qualifiedTableName({ databaseType, driverProfile, identifierQuote, database: table.database, schema: table.schema, catalog: table.catalog, tableName: table.tableName });
return `SELECT * FROM ${ref}`;
}
@@ -163,5 +164,5 @@ export function resolveNewQueryInitialSql(input: ResolveNewQueryInitialSqlInput)
const table = resolveNewQueryTable(input);
if (!table || table.connectionId !== input.targetConnectionId || table.database !== input.targetDatabase) return undefined;
return buildSelectAllSql(input.databaseType, table, input.identifierQuote);
return buildSelectAllSql(input.databaseType, table, input.identifierQuote, input.driverProfile);
}
@@ -1,5 +1,6 @@
import type { DatabaseType } from "@/types/database.ts";
import { isSchemaAware, usesDatabaseObjectTreeMode } from "@/lib/database/databaseCapabilities.ts";
import { jdbcDriverProfileUsesSchemaQualification } from "@/lib/database/jdbcDialect";
import * as api from "@/lib/backend/api.ts";
import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "@/lib/database/sqlServerLinkedServers.ts";
import { isExplicitlyQuotedSqlIdentifier, quoteGaussDbJdbcIdentifier } from "@/lib/sql/sqlIdentifier.ts";
@@ -57,6 +58,10 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
}
export function quoteTableDataIdentifier(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): string {
if (databaseType === "jdbc" && identifierQuote != null) {
if (!identifierQuote) return name;
return `${identifierQuote}${name.replaceAll(identifierQuote, identifierQuote + identifierQuote)}${identifierQuote}`;
}
if ((databaseType === "gaussdb" || databaseType === "opengauss" || databaseType === "postgres") && identifierQuote != null) return quoteGaussDbJdbcIdentifier(name, identifierQuote);
if ((databaseType === "kingbase" || databaseType === "informix" || databaseType === "spanner") && identifierQuote != null) {
if (!identifierQuote) return name;
@@ -103,6 +108,11 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
}
return quotedTable;
}
if (databaseType === "jdbc" && jdbcDriverProfileUsesSchemaQualification(driverProfile)) {
const quotedTable = quoteTableDataIdentifier(databaseType, tableName, identifierQuote);
const trimmedSchema = schema?.trim();
return trimmedSchema ? `${quoteTableDataIdentifier(databaseType, trimmedSchema, identifierQuote)}.${quotedTable}` : quotedTable;
}
// Cloud Spanner mirrors `uses_connection_identifier_quote` on the backend, which
// counts Spanner unconditionally: the branch must not be gated on a reported
// quote, otherwise a connection whose quote has not loaded yet would fall through
@@ -3,6 +3,7 @@ import { metricRangeQuery, qualifiedTableName, quoteTableDataIdentifier } from "
export interface TableSqlTemplateOptions {
databaseType?: DatabaseType;
driverProfile?: string;
identifierQuote?: string;
schema?: string;
catalog?: string;
@@ -100,6 +101,7 @@ export function buildTableDeleteTemplate(options: TableSqlTemplateOptions): stri
function templateTableName(options: TableSqlTemplateOptions): string {
return qualifiedTableName({
databaseType: options.databaseType,
driverProfile: options.driverProfile,
identifierQuote: options.identifierQuote,
catalog: options.catalog,
database: options.database,