fix(navigation): retry table opens without the row locator on rejection

Navigation-target table opens eagerly inject the PostgreSQL tuple
locator (tableoid/ctid/xmin) for keyless base tables, but unlike the
sidebar open and data-tab refresh flows they had no rejection fallback.
Engines without system-column support (Hologres, #12647) failed every
such open outright while views kept working. Reuse the same degradation
contract: retry once without the locator and remember the connection.
This commit is contained in:
t8y2
2026-11-13 01:40:01 +08:00
parent be8b89a9e4
commit a099c06ff6
2 changed files with 49 additions and 3 deletions
@@ -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();
});
});
@@ -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 +