diff --git a/apps/desktop/src/lib/metadata/tableMetadataCache.ts b/apps/desktop/src/lib/metadata/tableMetadataCache.ts index 5f7410c22..df4cbdce1 100644 --- a/apps/desktop/src/lib/metadata/tableMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/tableMetadataCache.ts @@ -47,6 +47,7 @@ const tableMetadataCache = new MetadataResultCache({ const tableMetadataCoordinator = new MetadataLoadCoordinator((event) => { console.debug("[DBX][metadata-load:table-coordinator]", event); }); +const tableIndexesLoads = new Map; promise: Promise; expiresAt: number }>(); // 失效代数(按 scope key 隔离):跨越失效边界的旧加载完成后不得写缓存—— // 结构变更后 force 拉到的新值可能被保存前启动、最后返回的在途加载回填覆盖。 @@ -114,6 +115,26 @@ export function tableMetadataToDataTabMeta(metadata: TableMetadata, schema = met }; } +export async function loadTableIndexes(request: TableMetadataRequest): Promise { + const scope = tableMetadataScope(request); + const scopeKey = metadataScopeKey(scope); + const cached = tableIndexesLoads.get(scopeKey); + if (!request.force && cached && cached.expiresAt > Date.now()) return cached.promise; + if (cached) tableIndexesLoads.delete(scopeKey); + while (tableIndexesLoads.size >= TABLE_METADATA_CACHE_MAX_ENTRIES) { + const oldestKey = tableIndexesLoads.keys().next().value; + if (!oldestKey) break; + tableIndexesLoads.delete(oldestKey); + } + const promise = api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); + const entry = { parts: metadataScopeParts(scope), promise, expiresAt: Date.now() + TABLE_METADATA_CACHE_TTL_MS }; + tableIndexesLoads.set(scopeKey, entry); + void promise.catch(() => { + if (tableIndexesLoads.get(scopeKey) === entry) tableIndexesLoads.delete(scopeKey); + }); + return promise; +} + export async function loadTableMetadata(request: TableMetadataRequest): Promise { const scope = tableMetadataScope(request); const trace = createMetadataLoadTrace(scope); @@ -138,8 +159,12 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< metadata = await tableMetadataCoordinator.run( scope, async () => { - const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); - const indexes = columns.length > 0 ? await api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog).catch((): IndexInfo[] => []) : []; + // Column discovery can be especially slow on Oracle. Start row-identity + // discovery independently so query preflight can reuse it without waiting. + const columnsPromise = api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); + const indexesPromise = loadTableIndexes(request).catch((): IndexInfo[] => []); + const columns = await columnsPromise; + const indexes = columns.length > 0 ? await indexesPromise : []; const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType); return { schema: request.schema || undefined, @@ -182,6 +207,9 @@ export function invalidateTableMetadataCache(match: MetadataCacheInvalidation): bumpTableMetadataInvalidationStamp(scopeKey); tableMetadataCoordinator.clear(scopeKey); } + for (const [scopeKey, entry] of tableIndexesLoads) { + if (matches(entry.parts)) tableIndexesLoads.delete(scopeKey); + } return tableMetadataCache.invalidate(match); } @@ -190,5 +218,6 @@ export function clearTableMetadataCache(): void { bumpTableMetadataInvalidationStamp(scopeKey); } tableMetadataCoordinator.clear(); + tableIndexesLoads.clear(); tableMetadataCache.clear(); } diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index e2628c206..8bb212598 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -23,6 +23,14 @@ const editorSettings = { autoCalculateTotalRows: false, }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + vi.mock("@/lib/backend/api", () => ({ analyzeEditableQueryEditability, buildDataGridCountSql, @@ -291,11 +299,50 @@ describe("queryStore hidden primary key editing", () => { await store.executeTabSql(tabId, "SELECT * FROM aa"); expect(getColumns).toHaveBeenCalledWith("oracle-1", "ORCL", "", "AA", undefined); - expect(listIndexes).not.toHaveBeenCalled(); + expect(listIndexes).toHaveBeenCalledWith("oracle-1", "ORCL", "", "AA", undefined); expect(listObjects).not.toHaveBeenCalled(); expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT * FROM aa", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); }); + it("starts an Oracle primary-key star query before slow column metadata finishes", async () => { + const columnsGate = deferred>>(); + getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 }); + getColumns.mockReturnValue(columnsGate.promise); + listIndexes.mockResolvedValue([{ name: "PK_WIDE_TABLE", columns: ["ID"], is_unique: true, is_primary: true }]); + analyzeEditableQueryEditability.mockImplementation(async () => ({ + editable: true, + analysis: { + schema: "APP", + tableName: "WIDE_TABLE", + tableAlias: "t", + selectStar: true, + columns: [], + }, + })); + executeMulti.mockResolvedValue([ + { + columns: ["ID", "NAME"], + rows: [[1, "Alice"]], + affected_rows: 0, + execution_time_ms: 312, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "ORCL", "Query"); + + const execution = store.executeTabSql(tabId, "SELECT t.* FROM APP.WIDE_TABLE t"); + await vi.waitFor(() => expect(executeMulti).toHaveBeenCalled()); + expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT t.* FROM APP.WIDE_TABLE t", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + + columnsGate.resolve([ + { name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "NAME", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + await execution; + }); + it("keeps a keyless Oracle query editable when its WHERE clause reads another table", 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 cfc4742b0..60acecfbb 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2,7 +2,7 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; import { computed, markRaw, onScopeDispose, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan"; @@ -36,7 +36,7 @@ import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; -import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache"; +import { getCachedTableMetadata, loadTableIndexes, loadTableMetadata, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; @@ -2420,6 +2420,12 @@ export const useQueryStore = defineStore("query", () => { tableMeta: NonNullable; }; + type EditableSourceMetadataTarget = { + source: EditableQuerySource; + analysis: EditableQueryInfo; + request: TableMetadataRequest; + }; + interface EditableQueryExecutionPreparation { sql: string; metadataSql: string; @@ -2434,7 +2440,7 @@ export const useQueryStore = defineStore("query", () => { tab.tableMeta = patch.tableMeta; } - async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string, traceId?: string, elapsed?: () => string): Promise { + function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget { // 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. @@ -2458,22 +2464,49 @@ export const useQueryStore = defineStore("query", () => { tableName: metadataTableName, }; const knownTableType = tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined; + return { + source: metadataSource, + analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), + request: { + connectionId: tab.connectionId!, + database: metadataDatabase, + schema: metadataSchema, + tableName: metadataTableName, + tableType: knownTableType, + databaseType: dbType, + driverProfile: conn?.driver_profile || conn?.db_type, + catalog: metadataCatalog, + }, + }; + } + + function loadedEditableSourceFromMetadata(target: EditableSourceMetadataTarget, metadata: Awaited>["metadata"]): LoadedEditableSource { + return { + source: target.source, + analysis: target.analysis, + tableMeta: { + catalog: target.request.catalog, + database: target.request.database, + schema: target.request.schema || undefined, + tableName: target.request.tableName, + tableType: metadata.tableType, + columns: metadata.columns, + primaryKeys: metadata.primaryKeys, + }, + }; + } + + async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string, traceId?: string, elapsed?: () => string): Promise { + const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase); queryExecutionLog("info", "metadata:table:start", { traceId, - schema: metadataSchema, - table: metadataTableName, + schema: target.request.schema, + table: target.request.tableName, alias: source.alias, elapsed: elapsed?.(), }); const loadedMetadata = await loadTableMetadata({ - connectionId: tab.connectionId, - database: metadataDatabase, - schema: metadataSchema, - tableName: metadataTableName, - tableType: knownTableType, - databaseType: dbType, - driverProfile: conn?.driver_profile || conn?.db_type, - catalog: metadataCatalog, + ...target.request, traceLogger: (event) => queryExecutionLog("debug", "metadata:table-trace", { sourceTraceId: traceId, ...event }), }); const columns = loadedMetadata.metadata.columns; @@ -2486,19 +2519,7 @@ export const useQueryStore = defineStore("query", () => { ageMs: Math.round(loadedMetadata.ageMs), elapsed: elapsed?.(), }); - return { - source: metadataSource, - analysis: normalizeOracleLikeQueryAnalysis(dbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), - tableMeta: { - catalog: metadataCatalog, - database: metadataDatabase, - schema: metadataSchema || undefined, - tableName: metadataTableName, - tableType: loadedMetadata.metadata.tableType, - columns, - primaryKeys, - }, - }; + return loadedEditableSourceFromMetadata(target, loadedMetadata.metadata); } function missingPrimaryKeysForSource(primaryKeys: string[], analysis: EditableQueryInfo, sourceKey: string): string[] { @@ -2515,6 +2536,36 @@ export const useQueryStore = defineStore("query", () => { return matching?.object_type.trim().toUpperCase() === "TABLE"; } + function primaryKeyIndex(indexes: IndexInfo[]): IndexInfo | undefined { + return indexes.find((index) => !index.filter && index.columns.length > 0 && index.is_primary); + } + + function buildHiddenPrimaryKeyPreparation(sql: string, databaseType: DatabaseType, loaded: LoadedEditableSource, primaryKeys: string[], declaredPrimaryKeys: string[], traceId: string, elapsed: () => string): EditableQueryExecutionPreparation { + const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [] }; + const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(databaseType, loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns); + const missingPrimaryKeys = declaredPrimaryKeys.length === 0 ? primaryKeys : missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key); + if (missingPrimaryKeys.length === 0) return unchanged; + const primaryKeySet = new Set(primaryKeys); + const hasWritableProjection = metadataAnalysis.selectStar ? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name)) : metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName)); + if (!hasWritableProjection) return unchanged; + + const rewritten = buildQueryWithHiddenPrimaryKeys({ + sql, + databaseType, + primaryKeys: missingPrimaryKeys, + existingResultNames: metadataAnalysis.selectStar ? loaded.tableMeta.columns.map((column) => column.name) : metadataAnalysis.columns.map((column) => column.resultName), + sourceExpressions: databaseType === "oracle" && missingPrimaryKeys.includes(DBX_ROWID_COLUMN) ? { [DBX_ROWID_COLUMN]: "ROWIDTOCHAR(ROWID)" } : undefined, + }); + if (!rewritten) return unchanged; + queryExecutionLog("info", "hidden-primary-keys", { + traceId, + table: loaded.tableMeta.tableName, + keyCount: rewritten.projections.length, + elapsed: elapsed(), + }); + return { sql: rewritten.sql, metadataSql: rewritten.sql, hiddenPrimaryKeys: rewritten.projections }; + } + 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) return unchanged; @@ -2526,38 +2577,36 @@ 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, executionDatabase, traceId, elapsed); + const target = resolveEditableSourceMetadataTarget(tab, analysis, sources[0]!, conn, databaseType, executionDatabase); + const cached = getCachedTableMetadata(target.request); + let loaded = cached ? loadedEditableSourceFromMetadata(target, cached.metadata) : undefined; + if (!cached && databaseType === "oracle") { + // Oracle column discovery can be slow. A star projection over a table + // with a declared primary key already returns the complete row identity, + // so SQL can start while the full metadata needed for editing loads. + const fullMetadataPromise = loadTableMetadata({ + ...target.request, + traceLogger: (event) => queryExecutionLog("debug", "metadata:table-trace", { sourceTraceId: traceId, ...event }), + }); + void fullMetadataPromise.catch((error) => queryExecutionLog("warn", "metadata:table-prefetch:failed", { traceId, error, elapsed: elapsed() })); + const indexes = await loadTableIndexes(target.request); + const projectsAllColumns = target.analysis.selectStar || target.analysis.columns.some((column) => column.star && (!column.sourceKey || column.sourceKey === target.source.key)); + if (primaryKeyIndex(indexes) && projectsAllColumns) { + return unchanged; + } + loaded = loadedEditableSourceFromMetadata(target, (await fullMetadataPromise).metadata); + } + + 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); const declaredPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name); // Oracle base tables without declared keys use the same ROWID identity as // table-data tabs. Confirm the object is a base table because selecting // ROWID from a view can fail with ORA-01445. if (databaseType === "oracle" && declaredPrimaryKeys.length === 0 && !(await oracleRowIdIsSafeForQuery(tab, loaded))) return unchanged; const primaryKeys = editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType); - - const missingPrimaryKeys = declaredPrimaryKeys.length === 0 ? primaryKeys : missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key); - if (missingPrimaryKeys.length === 0) return unchanged; - const primaryKeySet = new Set(primaryKeys); - const hasWritableProjection = metadataAnalysis.selectStar ? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name)) : metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName)); - if (!hasWritableProjection) return unchanged; - - const rewritten = buildQueryWithHiddenPrimaryKeys({ - sql, - databaseType, - primaryKeys: missingPrimaryKeys, - existingResultNames: metadataAnalysis.selectStar ? loaded.tableMeta.columns.map((column) => column.name) : metadataAnalysis.columns.map((column) => column.resultName), - sourceExpressions: databaseType === "oracle" && missingPrimaryKeys.includes(DBX_ROWID_COLUMN) ? { [DBX_ROWID_COLUMN]: "ROWIDTOCHAR(ROWID)" } : undefined, - }); - if (!rewritten) return unchanged; - queryExecutionLog("info", "hidden-primary-keys", { - traceId, - table: loaded.tableMeta.tableName, - keyCount: rewritten.projections.length, - elapsed: elapsed(), - }); - return { sql: rewritten.sql, metadataSql: rewritten.sql, hiddenPrimaryKeys: rewritten.projections }; + return buildHiddenPrimaryKeyPreparation(sql, databaseType, loaded, primaryKeys, declaredPrimaryKeys, traceId, elapsed); } catch (error) { // Metadata enrichment is optional. Query execution must retain its prior // behavior when metadata is unavailable or the SQL cannot be rewritten. diff --git a/packages/app-tests/tableMetadataCache.test.ts b/packages/app-tests/tableMetadataCache.test.ts index d555f598e..5135239a0 100644 --- a/packages/app-tests/tableMetadataCache.test.ts +++ b/packages/app-tests/tableMetadataCache.test.ts @@ -37,7 +37,7 @@ test("table metadata loader deduplicates equivalent in-flight requests and cache }; const first = loadTableMetadata(request); const second = loadTableMetadata(request); - await Promise.resolve(); + await vi.waitFor(() => assert.equal(listIndexes.mock.calls.length, 1)); assert.equal(getColumns.mock.calls.length, 1); columnsGate.resolve([