diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index 0010ef1771..448736b053 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -354,4 +354,36 @@ describe("useNavigationTargets openTableTarget", () => { // 无取消请求:元数据落地后按既有行为执行第二次查询 expect(mocks.executeTabSql).toHaveBeenCalledTimes(2); }); + + it("retries without the synthetic row-id locator when the engine rejects system columns (Hologres, #12647)", async () => { + const { resetRowIdLocatorStateForTests, rowIdLocatorUnsupported } = await import("@/lib/table/tableRowIdSupport"); + resetRowIdLocatorStateForTests(); + expect(rowIdLocatorUnsupported("connection-1")).toBe(false); + // Hologres-like: keyless PostgreSQL base table; the engine rejects every + // locator-bearing SELECT (tableoid/ctid/xmin do not exist there). + mocks.databaseType = "postgres"; + mocks.getColumns.mockResolvedValue([{ ...column("payload"), is_primary_key: false }]); + mocks.listIndexes.mockResolvedValue([]); + mocks.buildTableSelectSql.mockImplementation(async ({ tableName, includeRowId }: { tableName: string; includeRowId?: boolean }) => (includeRowId ? `SELECT tableoid::text || ':' || ctid::text || ':' || xmin::text AS "__DBX_ROWID", * FROM ${tableName}` : `SELECT * FROM ${tableName}`)); + mocks.executeTabSql.mockImplementation(async (id: string, sql?: string) => { + const tab = mocks.tabs.find((item) => item.id === id); + if (!tab || typeof sql !== "string") return; + if (sql.includes("__DBX_ROWID")) { + tab.result = { columns: ["db error"], rows: [['ERROR: column "tableoid" does not exist']], affected_rows: 0, execution_time_ms: 1, execution_error: true } as QueryTab["result"]; + } else { + tab.result = { columns: ["payload"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 } as QueryTab["result"]; + } + }); + + await useNavigationTargets(dialogs).openTableTarget({ connectionId: "connection-1", database: "app", tableName: "users", tableType: "TABLE" }); + + const executedSql = mocks.executeTabSql.mock.calls.map((call) => String(call[1])); + expect(executedSql.some((sql) => sql.includes("__DBX_ROWID"))).toBe(true); + // Same degradation contract as the sidebar open path: replay once without + // the locator so the table still opens (read-only), then remember the + // connection so later opens skip the locator up front. + expect(executedSql.some((sql) => !sql.includes("__DBX_ROWID") && sql.includes("FROM users"))).toBe(true); + expect(rowIdLocatorUnsupported("connection-1")).toBe(true); + resetRowIdLocatorStateForTests(); + }); }); diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 6525f2580a..bebd363416 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -7,7 +7,7 @@ import { buildTableSelectSql, requiresEagerTableMetadataForDataOpen } from "@/li import { resolveTableDefaultSort, applyTableDefaultSortResult } from "@/lib/table/tableDefaultSort"; import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; import { editableRowIdentifierColumns, physicalTablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing"; -import { rowIdLocatorUnsupported } from "@/lib/table/tableRowIdSupport"; +import { markRowIdLocatorUnsupported, rowIdLocatorUnsupported, shouldKeepRowIdLocatorDisabled, shouldRetryWithoutRowIdLocator } from "@/lib/table/tableRowIdSupport"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { uuid } from "@/lib/common/utils"; import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration"; @@ -220,7 +220,7 @@ async function openTableTarget(target: NavigationTarget, options: NavigationOpen const eagerPrimaryKeys = eagerMetadata?.metadata.primaryKeys ?? []; const eagerIncludeRowId = Boolean(eagerMetadata) && shouldIncludeSyntheticRowId(effectiveDbType, eagerPrimaryKeys, targetTableType) && !rowIdLocatorUnsupported(target.connectionId); const defaultSort = resolveTableDefaultSort(settingsStore.editorSettings, effectiveDbType, physicalTablePrimaryKeys(eagerColumns, eagerMetadata?.metadata.indexes, effectiveDbType), identifierQuote); - const sql = await buildTableSelectSql({ + const tableSelectSqlOptions = { databaseType: effectiveDbType, driverProfile: config.driver_profile, serverVersion: connectionStore.getConfig(target.connectionId)?.database_info?.productVersion, @@ -238,7 +238,8 @@ async function openTableTarget(target: NavigationTarget, options: NavigationOpen ...tableDataLargeValuePreviewOptions(effectiveDbType, eagerColumns, eagerPrimaryKeys, pageLimit, config.db_type === "jdbc"), whereInput: target.whereInput, limit: pageLimit, - }); + }; + const sql = await buildTableSelectSql(tableSelectSqlOptions); if (!isPreparationCurrent() || !isCurrentGeneration()) return; queryStore.updateSql(tabId, sql); queryStore.setTableMeta(tabId, { @@ -265,6 +266,19 @@ async function openTableTarget(target: NavigationTarget, options: NavigationOpen const cancelRequestedDuringExecute = (tabAfterFirstExecute?.cancelRequestCount ?? 0) > cancelCountBeforeExecute; const firstQueryFailed = cancelRequestedDuringExecute || tabAfterFirstExecute?.isCancelling === true || (firstResult !== undefined && isQueryExecutionErrorResult(firstResult)); if (!firstQueryFailed && targetTab) applyTableDefaultSortResult(targetTab, defaultSort, queryStore.sortTabResultLocally); + // 引擎拒绝合成行定位器(Hologres 等无系统列的 PG 兼容引擎拒 tableoid/ + // ctid/xmin,#12647):首查 SQL 带 __DBX_ROWID 而失败时,去掉定位器重试 + // 一次,表仍可打开(只读);成功后记住该连接,后续打开直接跳过定位器。 + // 与侧边栏打开/数据页签刷新路径同一降级契约;用户主动停止不重试。 + if (!cancelRequestedDuringExecute && tabAfterFirstExecute?.isCancelling !== true && shouldRetryWithoutRowIdLocator(sql, firstResult)) { + const fallbackSql = await buildTableSelectSql({ ...tableSelectSqlOptions, includeRowId: false }); + if (!isCurrentTarget() || !isCurrentGeneration()) return; + queryStore.updateSql(tabId, fallbackSql); + await queryStore.executeTabSql(tabId, fallbackSql, { pagination: { limit: pageLimit, offset: 0 } }); + if (!isCurrentTarget()) return; + const retryTab = queryStore.tabs.find((tab) => tab.id === tabId); + if (shouldKeepRowIdLocatorDisabled(retryTab?.result)) markRowIdLocatorUnsupported(target.connectionId); + } // executeTabSql surfaces query failures as an "Error" result instead of throwing. // A snapshot-less lake table fails the data preview above but its metadata still // reads fine — retry with LIMIT 0 so the user sees the table structure (columns +