diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 34322c3bcb..5640738638 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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 { 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)), diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index d8ec428d13..253c8a1704 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -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); diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 4b284b02ad..4f27924f73 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -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; diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index d1148c6b04..3337230202 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -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. diff --git a/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts index 65a2d90ee0..3c60e7b470 100644 --- a/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts @@ -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 }])); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 18aed2f1ed..3f7631a53c 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -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; } diff --git a/apps/desktop/src/lib/savedSql/savedSqlImportTarget.ts b/apps/desktop/src/lib/savedSql/savedSqlImportTarget.ts index 1b8a68da12..58ac107c44 100644 --- a/apps/desktop/src/lib/savedSql/savedSqlImportTarget.ts +++ b/apps/desktop/src/lib/savedSql/savedSqlImportTarget.ts @@ -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, }; } diff --git a/apps/desktop/src/lib/sql/externalSqlFileTarget.ts b/apps/desktop/src/lib/sql/externalSqlFileTarget.ts index d39a7caf57..667668ae13 100644 --- a/apps/desktop/src/lib/sql/externalSqlFileTarget.ts +++ b/apps/desktop/src/lib/sql/externalSqlFileTarget.ts @@ -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 }; } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d385847646..f9afe0da52 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 9494083f65..e54dd2b10d 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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, diff --git a/packages/app-tests/externalSqlFileTargetPersistence.test.ts b/packages/app-tests/externalSqlFileTargetPersistence.test.ts index de161197c5..60ae77d2ca 100644 --- a/packages/app-tests/externalSqlFileTargetPersistence.test.ts +++ b/packages/app-tests/externalSqlFileTargetPersistence.test.ts @@ -23,6 +23,9 @@ test("external SQL saves persist their selected data source before closing", () assert.ok(write < remember); assert.ok(remember < close); assert.ok(source.includes("catalog: tab.catalog")); + // The schema is part of the saved target too: without it a reopened file lands + // on the connection default and sidebar locate misses the table (issue #7648). + assert.ok(source.includes("schema: tab.schema")); }); test("external SQL open entry points restore a saved data source", () => { @@ -31,14 +34,14 @@ test("external SQL open entry points restore a saved data source", () => { const startupTabOpen = startupOpen.indexOf("queryStore.openExternalSqlFile"); assert.ok(startupResolve >= 0); assert.ok(startupResolve < startupTabOpen); - assert.ok(startupOpen.includes("snapshot.version, target.catalog")); + assert.ok(startupOpen.includes("snapshot.version, target.catalog, target.schema")); const panelOpen = functionSource(sqlFilePanelSource, "async function openFile", "function executeFile"); const panelResolve = panelOpen.indexOf("resolveExternalSqlFileTarget"); const panelTabOpen = panelOpen.indexOf("queryStore.openExternalSqlFile"); assert.ok(panelResolve >= 0); assert.ok(panelResolve < panelTabOpen); - assert.ok(panelOpen.includes("snapshot.version, target.catalog")); + assert.ok(panelOpen.includes("snapshot.version, target.catalog, target.schema")); const pickerOpen = functionSource(appSource, "async function openSqlFile()", "async function importResultArchive"); assert.ok(pickerOpen.includes("applyExternalSqlFileTarget(tab, sqlPath)")); @@ -48,7 +51,14 @@ test("external SQL catalog changes persist their complete target", () => { const source = functionSource(appSource, "function changeActiveCatalog", "async function setActiveDatabaseAsDefault"); assert.ok(source.includes("queryStore.updateCatalog(tab.id, catalog, database)")); assert.ok(source.includes("rememberExternalSqlFileTarget")); - assert.ok(source.includes("{ connectionId: tab.connectionId, database, catalog }")); + assert.ok(source.includes("{ connectionId: tab.connectionId, database, catalog, schema: tab.schema }")); +}); + +test("external SQL schema changes persist their complete target", () => { + const source = functionSource(appSource, "function changeActiveSchema", "function openGitHub"); + assert.ok(source.includes("queryStore.updateSchema(tab.id, schema)")); + assert.ok(source.includes("rememberExternalSqlFileTarget")); + assert.ok(source.includes("{ connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema }")); }); test("external SQL overwrite and recreate actions keep checked-write preconditions", () => {