diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index 43006e35d..e2628c206 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -123,6 +123,95 @@ describe("queryStore hidden primary key editing", () => { expect(tab.queryEditabilityReason).toBeUndefined(); }); + it("loads metadata from the connection default database when the query tab database is empty", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "", "Query"); + + await store.executeTabSql(tabId, "SELECT name FROM users"); + + expect(getColumns).toHaveBeenCalledWith("mysql-1", "app", "app", "users", undefined); + expect(executeMulti).toHaveBeenCalledWith("mysql-1", "", "SELECT name, `id` AS `__DBX_PK_0` FROM users", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["name", "id"])); + expect(tab.tableMeta?.database).toBe("app"); + expect(tab.queryEditabilityReason).toBeUndefined(); + }); + + it("uses the connection default database for SQL library tabs without a saved database", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.openSavedSql({ + id: "saved-1", + connectionId: "mysql-1", + name: "users.sql", + database: "", + sql: "SELECT name FROM users", + createdAt: "2026-07-21T00:00:00.000Z", + updatedAt: "2026-07-21T00:00:00.000Z", + }); + + await store.executeTabSql(tabId, "SELECT name FROM users"); + + expect(getColumns).toHaveBeenCalledWith("mysql-1", "app", "app", "users", undefined); + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["name", "id"])); + expect(tab.queryEditabilityReason).toBeUndefined(); + }); + + it("preserves JDBC catalog metadata lookup when the tab uses the connection default database", async () => { + getConnectionConfig.mockReturnValue({ id: "jdbc-1", name: "JDBC MySQL", db_type: "jdbc", connection_string: "jdbc:mysql://localhost:3306/app", database: "app", query_timeout_secs: 30 }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("jdbc-1", "", "Query"); + + await store.executeTabSql(tabId, "SELECT name FROM users"); + + expect(getColumns).toHaveBeenCalledWith("jdbc-1", "app", "", "users", undefined); + expect(executeMulti).toHaveBeenCalledWith("jdbc-1", "", "SELECT name, `id` AS `__DBX_PK_0` FROM users", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["name", "id"])); + expect(tab.queryEditabilityReason).toBeUndefined(); + }); + + it("keeps an explicitly selected database instead of falling back to the connection default", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "analytics", "Query"); + + await store.executeTabSql(tabId, "SELECT name FROM users"); + + expect(getColumns).toHaveBeenCalledWith("mysql-1", "analytics", "analytics", "users", undefined); + expect(executeMulti).toHaveBeenCalledWith("mysql-1", "analytics", "SELECT name, `id` AS `__DBX_PK_0` FROM users", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + }); + + it("loads metadata from a MySQL cross-database qualified source", async () => { + analyzeEditableQueryEditability.mockImplementation(async (sql: string) => { + const hidden = sql.includes("__DBX_PK_0"); + return { + editable: true, + analysis: { + schema: "reporting", + tableName: "users", + selectStar: false, + columns: [{ sourceName: "name", resultName: "name", expression: "name" }, ...(hidden ? [{ sourceName: "id", resultName: "__DBX_PK_0", expression: "`id`" }] : [])], + }, + }; + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + + await store.executeTabSql(tabId, "SELECT name FROM reporting.users"); + + expect(getColumns).toHaveBeenCalledWith("mysql-1", "app", "reporting", "users", undefined); + expect(executeMulti).toHaveBeenCalledWith("mysql-1", "app", "SELECT name, `id` AS `__DBX_PK_0` FROM reporting.users", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["name", "id"])); + expect(tab.tableMeta?.database).toBe("app"); + expect(tab.tableMeta?.schema).toBe("reporting"); + }); + it("uses a hidden Oracle ROWID to keep keyless base-table query results editable", async () => { getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 }); getColumns.mockResolvedValue([ diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index de9c4339d..1dff08060 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -38,7 +38,7 @@ import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; -import { connectionQueryExecutionSchema, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; +import { connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource"; import { sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort"; @@ -2390,7 +2390,11 @@ export const useQueryStore = defineStore("query", () => { tab.tableMeta = patch.tableMeta; } - async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, traceId?: string, elapsed?: () => string): Promise { + async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string, traceId?: string, elapsed?: () => string): Promise { + // Metadata must resolve in the same namespace as the query execution. An + // empty query-tab database still executes in the connection's default DB, + // while database-tree dialects may override it with a qualified source. + const metadataDatabase = (connectionUsesDatabaseObjectTreeMode(conn) ? source.schema : undefined) || executionDatabase || conn?.database || tab.database; let schema = source.schema || tab.schema; if (!schema) { if (dbType === "postgres" || dbType === "kwdb") schema = "public"; @@ -2399,7 +2403,7 @@ export const useQueryStore = defineStore("query", () => { // Oracle-family connection databases are service names, not schemas. When // the query does not qualify a schema, let the driver resolve the current // login user's schema instead of looking up metadata under the service name. - const resolvedSchema = ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema ? "" : metadataSchemaForConnection(conn, tab.database, schema || undefined); + const resolvedSchema = ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); const metadataSchema = normalizeOracleLikeMetadataIdentifier(dbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || ""; const metadataTableName = normalizeOracleLikeMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!; const metadataCatalog = normalizeOracleLikeMetadataIdentifier(dbType, source.catalog, source.catalogQuoted); @@ -2419,7 +2423,7 @@ export const useQueryStore = defineStore("query", () => { }); const loadedMetadata = await loadTableMetadata({ connectionId: tab.connectionId, - database: tab.database, + database: metadataDatabase, schema: metadataSchema, tableName: metadataTableName, tableType: knownTableType, @@ -2443,6 +2447,7 @@ export const useQueryStore = defineStore("query", () => { analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), tableMeta: { catalog: metadataCatalog, + database: metadataDatabase, schema: metadataSchema || undefined, tableName: metadataTableName, tableType: loadedMetadata.metadata.tableType, @@ -2461,14 +2466,14 @@ export const useQueryStore = defineStore("query", () => { async function oracleRowIdIsSafeForQuery(tab: QueryTab, loaded: LoadedEditableSource): Promise { const knownType = loaded.tableMeta.tableType?.trim().toUpperCase(); if (knownType) return knownType === "TABLE"; - const objects = await api.listObjects(tab.connectionId!, tab.database, loaded.tableMeta.schema ?? "", ["TABLE", "VIEW", "MATERIALIZED_VIEW"], loaded.tableMeta.tableName, 20, 0, loaded.tableMeta.catalog); + const objects = await api.listObjects(tab.connectionId!, loaded.tableMeta.database ?? tab.database, loaded.tableMeta.schema ?? "", ["TABLE", "VIEW", "MATERIALIZED_VIEW"], loaded.tableMeta.tableName, 20, 0, loaded.tableMeta.catalog); const matching = objects.find((object) => object.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase()); return matching?.object_type.trim().toUpperCase() === "TABLE"; } - async function prepareEditableQueryExecution(tab: QueryTab, sql: string, conn: ConnectionConfig | undefined, databaseType: DatabaseType | undefined, traceId: string, elapsed: () => string): Promise { + async function prepareEditableQueryExecution(tab: QueryTab, sql: string, conn: ConnectionConfig | undefined, databaseType: DatabaseType | undefined, executionDatabase: string, traceId: string, elapsed: () => string): Promise { const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [] }; - if (!databaseType || !HIDDEN_QUERY_KEY_DATABASE_TYPES.has(databaseType) || !tab.connectionId || !tab.database) return unchanged; + if (!databaseType || !HIDDEN_QUERY_KEY_DATABASE_TYPES.has(databaseType) || !tab.connectionId) return unchanged; try { const editability = analyzeEditableQueryEditability(sql); @@ -2477,7 +2482,7 @@ export const useQueryStore = defineStore("query", () => { const sources = editableQuerySources(analysis); if (sources.length !== 1 || analysis.distinct) return unchanged; - const loaded = await loadEditableQuerySource(tab, analysis, sources[0]!, conn, databaseType, traceId, elapsed); + const loaded = await loadEditableQuerySource(tab, analysis, sources[0]!, conn, databaseType, executionDatabase, traceId, elapsed); if (loaded.tableMeta.columns.length === 0) return unchanged; if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged; const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(databaseType, loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns); @@ -2517,7 +2522,7 @@ export const useQueryStore = defineStore("query", () => { } } - async function buildQueryMetadataPatch(tab: QueryTab, sql: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise { + async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise { if (tab.mode !== "query") return; if (!tab.result || !tab.result.columns.length) { return { @@ -2546,7 +2551,7 @@ export const useQueryStore = defineStore("query", () => { } const analysis = editability.analysis; - if (!tab.connectionId || !tab.database) { + if (!tab.connectionId) { return { queryAnalysis: undefined, querySourceColumns: undefined, @@ -2562,7 +2567,7 @@ export const useQueryStore = defineStore("query", () => { const loadedSources: LoadedEditableSource[] = []; try { for (const source of sources) { - loadedSources.push(await loadEditableQuerySource(tab, analysis, source, conn, dbType, traceId, elapsed)); + loadedSources.push(await loadEditableQuerySource(tab, analysis, source, conn, dbType, executionDatabase, traceId, elapsed)); } const allSourceColumns = loadedSources.map((source) => ({ source: source.source, columns: source.tableMeta.columns })); @@ -2675,12 +2680,12 @@ export const useQueryStore = defineStore("query", () => { } } - function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, traceId: string, elapsed: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []) { + function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []) { void (async () => { const tab = tabs.value.find((t) => t.id === tabId); if (!tab || tab.result !== result) return; queryExecutionLog("info", "metadata:start", { traceId, elapsed: elapsed() }); - const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed, hiddenPrimaryKeys); + const patch = await buildQueryMetadataPatch(tab, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys); if (patch?.queryAnalysis && hiddenPrimaryKeys.length > 0) { patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false }; } @@ -3303,7 +3308,7 @@ export const useQueryStore = defineStore("query", () => { } if (tab.mode === "query") { - const prepared = await prepareEditableQueryExecution(tab, sqlToExecute, conn, effectiveDbType, traceId, elapsed); + const prepared = await prepareEditableQueryExecution(tab, sqlToExecute, conn, effectiveDbType, executionDatabase, traceId, elapsed); sqlToExecute = prepared.sql; queryMetadataSql = prepared.metadataSql; hiddenPrimaryKeys = prepared.hiddenPrimaryKeys; @@ -3491,7 +3496,7 @@ export const useQueryStore = defineStore("query", () => { elapsed: elapsed(), }); if (current.mode === "query" && current.result) { - analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, traceId, elapsed, hiddenPrimaryKeys); + analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, hiddenPrimaryKeys); } } else { queryExecutionLog("warn", "stale-result", { @@ -3933,7 +3938,9 @@ export const useQueryStore = defineStore("query", () => { const sourceStatement = tab.result?.sourceStatement; if (tab.mode === "query" && sourceStatement && splitMongoCommandRanges(sourceStatement).length === 0) { const metadataStartedAt = performance.now(); - analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`); + const connection = useConnectionStore().getConfig(tab.connectionId); + const executionDatabase = dataTabExecutionDatabase(connection, tab.database, tab.catalog); + analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`); } }