fix(sql): restore SQL file schema for sidebar locate

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-09-01 16:21:40 +08:00
1 parent 440c41f5b9
commit e9a90173a6
11 files changed
+175 -53

No files matched your search

+15 -9
View File
@@ -1260,7 +1260,7 @@ async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: bo
expectedMissing: options.expectedMissing,
});
if (result.kind !== "written") return "retry";
rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog });
rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema });
queryStore.markExternalSqlFileSaved(tab.id, result.version);
toast(t("savedSql.saved"), 2000);
if (options.closeAfterSave) queryStore.closeTab(tab.id, { force: true });
@@ -1593,7 +1593,7 @@ async function saveExternalSqlTabAs(tab: QueryTab): Promise<boolean> {
const saved = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), await formattedSqlForSave(tab));
if (!saved) return false;
queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version);
rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog });
rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema });
invalidateSaveSqlFolderSelection();
showSaveSqlDialog.value = false;
closePendingSavedTab();
@@ -1619,6 +1619,9 @@ function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
if (target.catalog !== undefined || tab.catalog !== undefined) queryStore.updateCatalog(tab.id, target.catalog, target.database);
else queryStore.updateDatabase(tab.id, target.database);
}
// updateConnection/updateCatalog/updateDatabase all reset the schema, so the
// remembered schema has to be reapplied after them.
if (target.schema !== tab.schema) queryStore.updateSchema(tab.id, target.schema);
}
async function openSqlFile() {
@@ -1689,7 +1692,7 @@ async function openSqlFilePath(path: string) {
await desktopOpenTabsRestorationBarrier?.settled;
const snapshot = await api.readExternalSqlFileSnapshot(path);
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog);
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
}
@@ -2146,14 +2149,14 @@ async function changeActiveConnection(connectionId: string) {
if (!connection) return;
const initialDatabase = resolveDefaultDatabase(connection, []);
queryStore.updateConnection(tab.id, connectionId, initialDatabase);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database: initialDatabase, catalog: undefined });
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database: initialDatabase, catalog: undefined, schema: undefined });
connectionStore.activeConnectionId = connectionId;
try {
await connectionStore.ensureConnected(connectionId);
const options = await getDatabaseOptions(connectionId);
const database = resolveDefaultDatabase(connection, options);
queryStore.updateDatabase(tab.id, database);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined });
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined, schema: undefined });
if (connection.default_schema || connection.db_type === "oracle") {
try {
// A configured default wins. Otherwise Oracle returns the session's current schema first.
@@ -2161,6 +2164,7 @@ async function changeActiveConnection(connectionId: string) {
const schema = schemaAfterConnectionSwitch(connection.db_type, orderedSchemas, connection.default_schema);
if (schema && activeTab.value?.id === tab.id && activeTab.value.connectionId === connectionId) {
queryStore.updateSchema(tab.id, schema);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined, schema });
}
} catch {
// Schema metadata failure must not turn a successful connection switch into a connection error.
@@ -2180,7 +2184,7 @@ function changeActiveDatabase(database: string) {
const tab = activeTab.value;
if (tab) {
queryStore.updateDatabase(tab.id, database);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog: tab.catalog });
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog: tab.catalog, schema: tab.schema });
if (databaseRequiredTabId.value === tab.id && database) {
databaseRequiredTabId.value = null;
}
@@ -2191,7 +2195,7 @@ function changeActiveCatalog(catalog: string | undefined, database: string) {
const tab = activeTab.value;
if (tab) {
queryStore.updateCatalog(tab.id, catalog, database);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog });
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog, schema: tab.schema });
}
}
@@ -2209,7 +2213,9 @@ async function clearActiveDefaultDatabase() {
function changeActiveSchema(schema: string | undefined) {
const tab = activeTab.value;
if (tab) queryStore.updateSchema(tab.id, schema);
if (!tab) return;
queryStore.updateSchema(tab.id, schema);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema });
}
function openGitHub() {
@@ -2332,7 +2338,7 @@ async function handleQuickOpenSelect(item: any) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath);
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog);
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
toast(
externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)),
@@ -255,7 +255,7 @@ async function openFile(path: string) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(path);
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog);
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
if (isExternalSqlFileTooLargeError(e)) {
executeFile(path);
+1 -1
View File
@@ -27,7 +27,7 @@ export function useFileDrop() {
async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) {
if (path) {
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, path, content, version, target.catalog);
queryStore.openExternalSqlFile(target.connectionId, target.database, path, content, version, target.catalog, target.schema);
} else {
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
@@ -427,6 +427,41 @@ describe("object tree node schema", () => {
expect(metadataSchemaForConnection(connection, resourcePath, resourcePath)).toBe("");
});
it("never uses the database name as the schema when a schema-tree engine has no schema yet", () => {
// Query tabs can reach locate/metadata paths before a schema is picked (a
// toolbar "new query" tab, or an external .sql file reopened from disk).
// PostgreSQL and its relatives keep tables under a separate schema level, so
// `schema || database` sends "dbx_test" as the schema and matches nothing —
// locate in the sidebar silently does nothing (issue #7648). The blank schema
// lets the backend resolve the session default instead.
for (const dbType of ["postgres", "gaussdb", "opengauss", "kingbase", "redshift", "duckdb"] as const) {
expect(connectionObjectTreeQuerySchema({ db_type: dbType }, "dbx_test")).toBe("");
expect(connectionObjectTreeNodeSchema({ db_type: dbType }, "dbx_test")).toBeUndefined();
expect(metadataSchemaForConnection({ db_type: dbType }, "dbx_test")).toBe("");
// An explicit schema still wins, and the database name is a legitimate
// schema name when the user really did select it.
expect(connectionObjectTreeQuerySchema({ db_type: dbType }, "dbx_test", "public")).toBe("public");
expect(connectionObjectTreeNodeSchema({ db_type: dbType }, "dbx_test", "public")).toBe("public");
}
// SQL Server keeps its own dbo default for metadata lookups.
expect(connectionObjectTreeQuerySchema({ db_type: "sqlserver" }, "dbx_test")).toBe("");
expect(connectionObjectTreeNodeSchema({ db_type: "sqlserver" }, "dbx_test")).toBeUndefined();
expect(metadataSchemaForConnection({ db_type: "sqlserver" }, "dbx_test")).toBe("dbo");
});
it("keeps the database-as-schema fallback for engines whose database is the schema", () => {
// Oracle, Dameng and the Hive family address objects as schema.table with no
// separate schema level in the tree, so the database name is the schema there
// and must survive the fix above.
expect(connectionObjectTreeNodeSchema({ db_type: "oracle" }, "DBX_TEST")).toBe("DBX_TEST");
expect(connectionObjectTreeQuerySchema({ db_type: "oracle" }, "DBX_TEST")).toBe("DBX_TEST");
expect(connectionObjectTreeNodeSchema({ db_type: "dameng" }, "DBX_TEST")).toBe("DBX_TEST");
expect(connectionObjectTreeNodeSchema({ db_type: "hive" }, "CS")).toBe("CS");
expect(connectionObjectTreeNodeSchema({ db_type: "spark" }, "CS")).toBe("CS");
// Flat engines keep returning no tree schema at all.
expect(connectionObjectTreeNodeSchema({ db_type: "mysql" }, "shop")).toBeUndefined();
});
it("keeps the database-as-schema fallback for flat engines and blanks it for Cloud Spanner", () => {
// Editor completion paths send the database name as the metadata schema when a
// connection has no schema level; only Spanner must send a blank schema instead.
@@ -26,6 +26,26 @@ describe("external SQL file targets", () => {
expect(resolveExternalSqlFileTarget("/work/iceberg.sql", () => true, unassociatedExternalSqlFileTarget()).catalog).toBe("iceberg");
});
it("restores the schema the file was saved from", () => {
// Reopening a .sql file used to drop the schema, leaving the tab on the
// connection default: sidebar locate then looked for the table in the wrong
// namespace and silently did nothing (issue #7648).
rememberExternalSqlFileTarget("/work/report.sql", { connectionId: "saved-connection", database: "dbx_test", schema: "analytics" });
expect(resolveExternalSqlFileTarget("/work/report.sql", () => true, unassociatedExternalSqlFileTarget())).toEqual({
connectionId: "saved-connection",
database: "dbx_test",
catalog: undefined,
schema: "analytics",
});
});
it("treats historical records without a schema as unset", () => {
localStorage.setItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, JSON.stringify([{ path: "/work/legacy.sql", connectionId: "saved-connection", database: "sales", updatedAt: 1 }]));
expect(resolveExternalSqlFileTarget("/work/legacy.sql", () => true, unassociatedExternalSqlFileTarget()).schema).toBeUndefined();
});
it("treats historical records without catalog as the default catalog", () => {
localStorage.setItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, JSON.stringify([{ path: "/work/legacy.sql", connectionId: "saved-connection", database: "sales", updatedAt: 1 }]));
@@ -237,12 +237,28 @@ export function connectionQueryExecutionSchema(connection: JdbcDialectConnection
return type && DATABASE_AS_EXECUTION_SCHEMA_TYPES.has(type) ? database || undefined : undefined;
}
/**
* Whether the database name is a wrong guess for an unknown schema. Engines that keep tables under a
* dedicated schema level in the tree (PostgreSQL and relatives, SQL Server, DB2, Trino, generic JDBC)
* address objects as database.schema.table, so the database name matches no schema there. Oracle,
* Dameng and the Hive family are schema-aware without that level — their database *is* the schema —
* and must keep the `schema || database` fallback.
*/
function databaseNameIsNotASchema(type: DatabaseType | undefined): boolean {
return isSchemaAware(type) && usesTreeSchemaMode(type);
}
export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string {
if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database;
if (connectionUsesDatabaseObjectTreeMode(connection)) return "";
const type = effectiveDatabaseTypeForConnection(connection);
if (type === "informix") return schema || "";
if (type === "spanner") return spannerObjectTreeSchema(schema);
// A query tab that never picked a schema (toolbar "new query", a reopened .sql
// file) would otherwise send the database name and match no objects at all, so
// sidebar locate silently did nothing (issue #7648). The blank schema is the
// established "resolve it on the backend" value used by the completion paths.
if (!schema && databaseNameIsNotASchema(type)) return "";
return schema || database;
}
@@ -272,6 +288,7 @@ export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection
if (type === "sqlite") return schema || database;
if (type === "spanner") return spannerObjectTreeSchema(schema);
if (!type) return schema;
if (!schema && databaseNameIsNotASchema(type)) return undefined;
return isSchemaAware(type) ? schema || database : undefined;
}
@@ -7,5 +7,6 @@ export function savedSqlImportTarget(sourceTarget: ExternalSqlFileTarget, folder
connectionId: folder.connectionId,
database: sourceTarget.connectionId === folder.connectionId ? sourceTarget.database : "",
catalog: sourceTarget.connectionId === folder.connectionId ? sourceTarget.catalog : undefined,
schema: sourceTarget.connectionId === folder.connectionId ? sourceTarget.schema : undefined,
};
}
@@ -7,10 +7,11 @@ export interface ExternalSqlFileTarget {
connectionId: string;
database: string;
catalog?: string;
schema?: string;
}
export function unassociatedExternalSqlFileTarget(): ExternalSqlFileTarget {
return { connectionId: "", database: "", catalog: undefined };
return { connectionId: "", database: "", catalog: undefined, schema: undefined };
}
interface StoredExternalSqlFileTarget extends ExternalSqlFileTarget {
@@ -23,8 +24,18 @@ function loadExternalSqlFileTargets(): StoredExternalSqlFileTarget[] {
const parsed = JSON.parse(localStorage.getItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY) || "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => typeof item?.path === "string" && typeof item?.connectionId === "string" && typeof item?.database === "string" && (item?.catalog === undefined || typeof item.catalog === "string") && typeof item?.updatedAt === "number")
.map((item) => ({ ...item, catalog: item.catalog })) as StoredExternalSqlFileTarget[];
.filter(
(item) =>
typeof item?.path === "string" &&
typeof item?.connectionId === "string" &&
typeof item?.database === "string" &&
(item?.catalog === undefined || typeof item.catalog === "string") &&
// Targets written before schemas were remembered carry no schema key at
// all, so an absent schema stays valid and simply restores as undefined.
(item?.schema === undefined || typeof item.schema === "string") &&
typeof item?.updatedAt === "number",
)
.map((item) => ({ ...item, catalog: item.catalog, schema: item.schema })) as StoredExternalSqlFileTarget[];
} catch {
return [];
}
@@ -71,5 +82,5 @@ export function resolveExternalSqlFileTarget(path: string, connectionExists: (co
const normalizedPath = normalizeExternalSqlPath(path);
const saved = loadExternalSqlFileTargets().find((item) => item.path === normalizedPath);
if (!saved || !connectionExists(saved.connectionId)) return fallback;
return { connectionId: saved.connectionId, database: saved.database, catalog: saved.catalog };
return { connectionId: saved.connectionId, database: saved.database, catalog: saved.catalog, schema: saved.schema };
}
+52 -34
View File
@@ -5955,44 +5955,61 @@ export const useConnectionStore = defineStore("connection", () => {
async () => {
await ensureConnected(target.connectionId);
const querySchema = connectionObjectTreeQuerySchema(config, target.database, target.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema);
const pageSize = sidebarObjectGroupPageSize();
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
// A query tab can reach locate without a schema (a toolbar "new query" tab,
// or a reopened .sql file), and on a schema tree the table then lives under
// one of the database's schema nodes. Resolve the schema per node instead of
// guessing a single one for the whole database: the old `schema || database`
// guess matched nothing at all (issue #7648), and a single resolved schema
// would merge one schema's tables into every other schema's node.
const schemaForNode = (nodeSchema?: string) => target.schema ?? nodeSchema;
let loaded = false;
if (simpleObjectDisplay) {
const parentId = target.schema ? `${target.connectionId}:${target.database}:${target.schema}` : `${target.connectionId}:${target.database}`;
const parent = findNode(treeNodes.value, parentId);
if (!parent) return false;
let load = beginTreeNodeLoad(parent);
try {
load = reclaimTreeNodeLoad(load, parent);
const page = await loadPagedSimpleTableChildren({
nodeId: parentId,
connectionId: target.connectionId,
database: target.database,
querySchema,
effectiveSchema,
nonTableObjectTypes: [],
offset: 0,
pageSize,
searchFilter: target.tableName,
force: false,
});
if (!page.children.length) return false;
const targetParent = treeNodeLoadTarget(load);
if (!targetParent) return false;
const currentChildren = withoutLoadMoreNodes(targetParent.children);
const loadMoreNodes = (targetParent.children || []).filter((child) => child.type === "load-more");
const mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, target.connectionId, target.database);
setChildren(targetParent, [...mergedChildren, ...loadMoreNodes]);
targetParent.objectCount = Math.max(targetParent.objectCount ?? currentChildren.length, mergedChildren.length);
targetParent.isExpanded = true;
return true;
} finally {
finishTreeNodeLoad(load);
const schemaParents = target.schema
? [findNode(treeNodes.value, `${target.connectionId}:${target.database}:${target.schema}`)]
: findTreeNodes(treeNodes.value, (node) => node.type === "schema" && node.connectionId === target.connectionId && sameSidebarObjectName(node.database, target.database));
const parents = schemaParents.filter((node): node is TreeNode => !!node);
if (!parents.length) {
const databaseParent = target.schema ? null : findNode(treeNodes.value, `${target.connectionId}:${target.database}`);
if (databaseParent) parents.push(databaseParent);
}
if (!parents.length) return false;
for (const parent of parents) {
const parentSchema = schemaForNode(parent.type === "schema" ? (parent.schema ?? parent.label) : undefined);
let load = beginTreeNodeLoad(parent);
try {
load = reclaimTreeNodeLoad(load, parent);
const page = await loadPagedSimpleTableChildren({
nodeId: parent.id,
connectionId: target.connectionId,
database: target.database,
querySchema: connectionObjectTreeQuerySchema(config, target.database, parentSchema),
effectiveSchema: connectionObjectTreeNodeSchema(config, target.database, parentSchema),
nonTableObjectTypes: [],
offset: 0,
pageSize,
searchFilter: target.tableName,
force: false,
});
if (!page.children.length) continue;
const targetParent = treeNodeLoadTarget(load);
if (!targetParent) continue;
const currentChildren = withoutLoadMoreNodes(targetParent.children);
const loadMoreNodes = (targetParent.children || []).filter((child) => child.type === "load-more");
const mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, target.connectionId, target.database);
setChildren(targetParent, [...mergedChildren, ...loadMoreNodes]);
targetParent.objectCount = Math.max(targetParent.objectCount ?? currentChildren.length, mergedChildren.length);
targetParent.isExpanded = true;
loaded = true;
} finally {
finishTreeNodeLoad(load);
}
}
return loaded;
}
const matchingGroups = findTreeNodes(treeNodes.value, (node) => {
@@ -6012,11 +6029,12 @@ export const useConnectionStore = defineStore("connection", () => {
let load = beginTreeNodeLoad(group);
try {
load = reclaimTreeNodeLoad(load, group);
const groupSchema = schemaForNode(group.schema);
const page = await loadPagedTableGroupChildren({
node: group,
parentNodeId,
querySchema,
effectiveSchema,
querySchema: connectionObjectTreeQuerySchema(config, group.database || target.database, groupSchema),
effectiveSchema: connectionObjectTreeNodeSchema(config, group.database || target.database, groupSchema),
objectTypes,
offset: 0,
pageSize,
+5 -1
View File
@@ -1920,7 +1920,7 @@ export const useQueryStore = defineStore("query", () => {
});
}
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string) {
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string) {
const normalizedPath = normalizeExternalSqlPath(path);
const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath);
if (existing) {
@@ -1939,6 +1939,10 @@ export const useQueryStore = defineStore("query", () => {
connectionId,
database,
catalog,
// Restoring the schema keeps the reopened file on the namespace it was
// saved from. Without it the tab has no schema, so sidebar locate and the
// metadata paths fall back to the connection default (issue #7648).
schema,
sql,
originalSql: sql,
externalSqlPath: path,