fix(grid): support unique and virtual row identifiers

Refs: #10723
This commit is contained in:
t8y2
2026-10-04 20:30:10 +08:00
parent 02bc0cf47e
commit 2c31c6cf32
16 changed files with 568 additions and 40 deletions
@@ -84,6 +84,7 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
import DataGridToolbar from "@/components/grid/DataGridToolbar.vue";
import DataGridVirtualRowIdentifier from "@/components/grid/DataGridVirtualRowIdentifier.vue";
import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
@@ -317,6 +318,7 @@ import {
splitForeignKeyDisplayValues,
type ForeignKeyDisplayConfig,
} from "@/lib/dataGrid/dataGridForeignKeyDisplay";
import { removeVirtualRowIdentifier, saveVirtualRowIdentifier, type VirtualRowIdentifierScope } from "@/lib/table/virtualRowIdentifier";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -521,6 +523,7 @@ interface DataGridProps {
tableType?: string;
columns: ColumnInfo[];
primaryKeys: string[];
virtualPrimaryKeys?: string[];
};
tableInfoTab?: TableInfoTab;
autoShowTableInfo?: boolean;
@@ -4417,6 +4420,41 @@ const saveToolbarState = computed(() =>
const hasSearchBarSlot = computed(() => !!slots["search-bar"]);
const hasResultToolbarLeadingSlot = computed(() => !!slots["result-toolbar-leading"]);
const hasResultToolbarActionsSlot = computed(() => !!slots["result-toolbar-actions"]);
const virtualRowIdentifierScope = computed<VirtualRowIdentifierScope | undefined>(() => {
if (!props.connectionId || !props.database || !props.tableMeta?.tableName) return undefined;
return {
connectionId: props.connectionId,
database: props.tableMeta.database ?? props.database,
catalog: props.tableMeta.catalog,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
};
});
const virtualRowIdentifierColumns = computed(() => props.tableMeta?.virtualPrimaryKeys ?? []);
const showVirtualRowIdentifierControl = computed(() => {
if (!virtualRowIdentifierScope.value || !props.tableMeta?.columns.length || props.customSaveHandler) return false;
return virtualRowIdentifierColumns.value.length > 0 || props.tableMeta.primaryKeys.length === 0;
});
const virtualRowIdentifierDisabled = computed(() => props.loading === true || isSaving.value || hasPendingChanges.value);
function applyVirtualRowIdentifier(columns: string[]) {
const scope = virtualRowIdentifierScope.value;
const tableColumns = props.tableMeta?.columns;
if (!scope || !tableColumns || !saveVirtualRowIdentifier(scope, columns, tableColumns)) {
toast(t("grid.virtualRowIdentifierSaveFailed"), 5000);
return;
}
toast(t("grid.virtualRowIdentifierApplied"));
void reloadTableData("row-identifier-change");
}
function clearVirtualRowIdentifier() {
const scope = virtualRowIdentifierScope.value;
if (!scope) return;
removeVirtualRowIdentifier(scope);
toast(t("grid.virtualRowIdentifierCleared"));
void reloadTableData("row-identifier-change");
}
const quickEntryEnabled = computed(() => settingsStore.editorSettings.dataGridQuickEntry);
const showQuickEntryDraftRow = computed(() =>
shouldShowQuickEntryDraftRow({
@@ -4435,6 +4473,7 @@ const showDataGridTopbar = computed(
hasSearchBarSlot.value ||
hasResultToolbarLeadingSlot.value ||
hasResultToolbarActionsSlot.value ||
showVirtualRowIdentifierControl.value ||
showQueryEditReadOnlyBadge.value ||
props.context !== "results" ||
(!!props.editable && hasDataGridSaveTarget.value) ||
@@ -12869,6 +12908,15 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
>
<template #leading>
<slot v-if="hasResultToolbarActionsSlot" name="result-toolbar-actions" :compact="compactDataGridToolbar" />
<DataGridVirtualRowIdentifier
v-if="showVirtualRowIdentifierControl"
:columns="props.tableMeta!.columns"
:selected-columns="virtualRowIdentifierColumns"
:compact="compactDataGridToolbar"
:disabled="virtualRowIdentifierDisabled"
@apply="applyVirtualRowIdentifier"
@clear="clearVirtualRowIdentifier"
/>
<Tooltip v-if="showQueryEditReadOnlyBadge">
<TooltipTrigger as-child>
<div class="flex h-5 items-center gap-1 rounded border border-muted-foreground/30 bg-muted/60 px-1.5 text-xs font-medium text-muted-foreground">
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { AlertTriangle, KeyRound } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { ColumnInfo } from "@/types/database";
const props = defineProps<{
columns: ColumnInfo[];
selectedColumns: string[];
compact?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
apply: [columns: string[]];
clear: [];
}>();
const { t } = useI18n();
const open = ref(false);
const draftColumns = ref<string[]>([]);
const hasConfiguredKey = computed(() => props.selectedColumns.length > 0);
const canApply = computed(() => !props.disabled && draftColumns.value.length > 0);
watch(open, (nextOpen) => {
if (nextOpen) draftColumns.value = [...props.selectedColumns];
});
function toggleColumn(column: string, checked: boolean) {
const selected = new Set(draftColumns.value);
if (checked) selected.add(column);
else selected.delete(column);
draftColumns.value = props.columns.filter((candidate) => selected.has(candidate.name)).map((candidate) => candidate.name);
}
function apply() {
if (!canApply.value) return;
emit("apply", [...draftColumns.value]);
open.value = false;
}
function clear() {
if (props.disabled) return;
emit("clear");
open.value = false;
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
data-toolbar-action="virtual-row-identifier"
variant="ghost"
size="sm"
:class="['data-grid-topbar-action-button h-5 shrink-0 px-1.5 text-xs', compact ? 'data-grid-topbar-action-button--compact' : '', hasConfiguredKey ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-amber-700 hover:bg-amber-500/10 dark:text-amber-300']"
:disabled="disabled"
:aria-label="t('grid.virtualRowIdentifier')"
:aria-pressed="hasConfiguredKey"
:title="t('grid.virtualRowIdentifier')"
>
<KeyRound class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compact }">{{ t("grid.virtualRowIdentifier") }}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-0">
<div class="border-b px-3 py-2.5">
<div class="text-sm font-semibold">{{ t("grid.virtualRowIdentifierTitle") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("grid.virtualRowIdentifierDescription") }}</div>
</div>
<div class="flex gap-2 border-b bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("grid.virtualRowIdentifierWarning") }}</span>
</div>
<div class="max-h-64 overflow-auto p-1.5">
<label v-for="column in columns" :key="column.name" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-muted/70">
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="draftColumns.includes(column.name)" :disabled="disabled" @change="toggleColumn(column.name, ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate font-mono">{{ column.name }}</span>
<span v-if="column.is_nullable" class="shrink-0 text-[10px] text-muted-foreground">NULL</span>
</label>
</div>
<div class="flex items-center justify-between gap-2 border-t px-3 py-2">
<Button v-if="hasConfiguredKey" type="button" variant="ghost" size="sm" :disabled="disabled" @click="clear">{{ t("grid.virtualRowIdentifierClear") }}</Button>
<span v-else />
<Button type="button" size="sm" :disabled="!canApply" @click="apply">{{ t("grid.virtualRowIdentifierApply") }}</Button>
</div>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { computed, createApp, defineComponent, h, ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDataGridLargeValues } from "@/composables/useDataGridLargeValues";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
buildDataGridContextFilterCondition: vi.fn(),
buildTableSelectSql: vi.fn(),
cancelQuery: vi.fn(),
executeMulti: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
buildDataGridContextFilterCondition: mocks.buildDataGridContextFilterCondition,
cancelQuery: mocks.cancelQuery,
executeMulti: mocks.executeMulti,
}));
vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: mocks.buildTableSelectSql }));
const disposers: Array<() => void> = [];
afterEach(() => {
for (const dispose of disposers.splice(0)) dispose();
vi.clearAllMocks();
});
describe("useDataGridLargeValues", () => {
it("hydrates a deferred DB2 BLOB through its primary key", async () => {
const result = ref<QueryResult>({
columns: ["ID", "APP"],
column_types: ["BIGINT", "BLOB"],
rows: [[1, "<BLOB>"]],
affected_rows: 0,
execution_time_ms: 1,
large_value_cells: [{ row_index: 0, column_index: 1, original_bytes: 64 * 1024 * 1024 }],
});
const toast = vi.fn();
mocks.buildDataGridContextFilterCondition.mockResolvedValue('"ID" = 1');
mocks.buildTableSelectSql.mockResolvedValue('SELECT "ID", "APP" FROM "APP_DATA" WHERE "ID" = 1 FETCH FIRST 1 ROWS ONLY');
mocks.executeMulti.mockResolvedValue([
{
columns: ["ID", "APP"],
rows: [[1, "0x0102"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
let largeValues: ReturnType<typeof useDataGridLargeValues> | undefined;
const app = createApp(
defineComponent({
setup() {
largeValues = useDataGridLargeValues({
result: computed(() => result.value),
tableMeta: computed(() => ({
schema: "APP",
tableName: "APP_DATA",
tableType: "TABLE",
columns: [
{ name: "ID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "APP", data_type: "BLOB", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["ID"],
})),
databaseType: computed(() => "db2"),
connectionId: computed(() => "db2-1"),
executionDatabase: computed(() => "MAXIMO"),
resultSourceColumns: computed(() => ["ID", "APP"]),
allColumnTypes: computed(() => ["BIGINT", "BLOB"]),
renderedGridColumns: computed(() => []),
showTranspose: ref(false),
displayRowCount: computed(() => 1),
gridScrollerElement: () => null,
displayItemAt: () => undefined,
getRowItem: (rowId) =>
rowId === 1
? {
id: 1,
sourceIndex: 0,
data: result.value.rows[0]!,
isNew: false,
isDeleted: false,
isDirtyCol: [false, false],
}
: undefined,
tableColumnForGridColumn: () => undefined,
formatCell: (value) => String(value ?? ""),
formatCellCached: (value) => String(value ?? ""),
scheduleCanvasDraw: vi.fn(),
clearCellFormatCache: vi.fn(),
invalidateResultEstimate: vi.fn(),
connectionIdentifierQuote: () => '"',
getConnectionConfig: () => undefined,
includeDatabaseName: computed(() => true),
globalQueryTimeoutSecs: computed(() => 30),
resultLifecycle: { beginOperation: () => 1, isCurrent: () => true },
largeValueResolutionVersion: ref(0),
runtimeScope: { disposed: false, addCleanup: () => () => undefined, dispose: () => undefined },
uuid: () => "execution-1",
translate: (key) => key,
translateBackendError: (error) => String(error),
appendDebugLog: vi.fn(),
toast,
cloneRow: vi.fn(),
cloneRows: vi.fn(),
});
return () => h("div");
},
}),
);
app.mount(document.createElement("div"));
disposers.push(() => app.unmount());
if (!largeValues) throw new Error("useDataGridLargeValues was not mounted");
await expect(largeValues.hydrateLargeValueCell(1, 1)).resolves.toBe(true);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "db2", columns: ["ID", "APP"], primaryKeys: ["ID"] }));
expect(mocks.executeMulti).toHaveBeenCalledWith("db2-1", "MAXIMO", expect.any(String), undefined, "execution-1", {
maxRows: 1,
fetchSize: 1,
timeoutSecs: 60,
});
expect(result.value.rows).toEqual([[1, "0x0102"]]);
expect(result.value.large_value_cells).toEqual([]);
expect(toast).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,7 @@ import { tableDataLargeValuePreviewOptions, tableDataPreviewRowBudget } from "@/
import { elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination";
import { editablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
import { loadVirtualRowIdentifier } from "@/lib/table/virtualRowIdentifier";
import * as api from "@/lib/backend/api";
import type { ColumnInfo, QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast";
@@ -177,7 +178,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
// 复用共享表元数据缓存(30s TTL + in-flight 去重),多个入口对同一张表
// 不再各自往返 getColumns/listIndexes。跨连接生命周期的强制重建走 force,
// 避免同一共享缓存把断链前的旧列再次交回本次 reload。
const loaded: { columns: TableMetadataColumns; primaryKeys: string[]; rowIdentityResolved: boolean } = options.columnsOnly
const loaded: { columns: TableMetadataColumns; primaryKeys: string[]; virtualPrimaryKeys?: string[]; rowIdentityResolved: boolean } = options.columnsOnly
? await (async () => {
const { columns } = await loadTableColumns({
connectionId: target.connectionId,
@@ -191,7 +192,13 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
force: options.force === true,
});
const primaryKeys = editablePrimaryKeys(effectiveDatabaseTypeForConnection(config), columns, target.tableType);
return { columns, primaryKeys, rowIdentityResolved: false };
const virtualPrimaryKeys = primaryKeys.length > 0 ? [] : loadVirtualRowIdentifier({ connectionId: target.connectionId, database: target.database, catalog: target.catalog, schema: querySchema, tableName: target.tableName }, columns);
return {
columns,
primaryKeys: primaryKeys.length > 0 ? primaryKeys : virtualPrimaryKeys,
...(virtualPrimaryKeys.length ? { virtualPrimaryKeys } : {}),
rowIdentityResolved: primaryKeys.length > 0 || virtualPrimaryKeys.length > 0,
};
})()
: await (async () => {
const { metadata } = await loadTableMetadata({
@@ -205,7 +212,12 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
catalog: target.catalog,
force: options.force === true,
});
return { columns: metadata.columns, primaryKeys: metadata.primaryKeys, rowIdentityResolved: metadata.rowIdentityResolved !== false };
return {
columns: metadata.columns,
primaryKeys: metadata.primaryKeys,
...(metadata.virtualPrimaryKeys?.length ? { virtualPrimaryKeys: metadata.virtualPrimaryKeys } : {}),
rowIdentityResolved: metadata.rowIdentityResolved !== false,
};
})();
const columns = loaded.columns;
console.info("[DBX][reloadData:metadata:get-columns:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), columnCount: columns.length });
@@ -238,6 +250,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
tableType: target.tableType,
columns,
primaryKeys,
...(loaded.virtualPrimaryKeys?.length ? { virtualPrimaryKeys: loaded.virtualPrimaryKeys } : {}),
};
if (loaded.rowIdentityResolved) {
queryStore.setTableMeta(target.tabId, refreshedMeta);
@@ -341,7 +354,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
toast(e?.message || String(e), 5000);
});
};
if (lifecycleStale || intent === "refresh") {
if (lifecycleStale || intent === "refresh" || intent === "row-identifier-change") {
tab.tableMetaPending = true;
console.info("[DBX][reloadData:metadata:await:start]", { traceId, elapsed: elapsed(), reason: intent === "refresh" ? "manual-refresh" : "lifecycle-stale", metadataAgeMs });
try {
@@ -370,7 +383,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
// 手动刷新等待段只拉列(getColumns),不等 listIndexes:列投影决定
// 本次 SELECT 的正确性;主键/索引用旧值与新列求交,PK 名不进 SQL
// 文本(仅作大值预览保护集合),查询本身不受索引元数据延迟影响。
const rebuilt = await refreshDataTabTableMeta(tab, { force: true, columnsOnly: intent === "refresh" && !lifecycleStale && hasRealTableMetaColumns, trace: { traceId, elapsed } });
const rebuilt = await refreshDataTabTableMeta(tab, { force: intent === "refresh" || lifecycleStale, columnsOnly: intent === "refresh" && !lifecycleStale && hasRealTableMetaColumns, trace: { traceId, elapsed } });
console.info("[DBX][reloadData:metadata:await:done]", { traceId, elapsed: elapsed(), rebuilt });
if (!rebuilt) {
stopPreparing();
@@ -431,7 +444,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
pendingDataReloads.delete(tab);
}
}
if ((intent === "refresh" || intent === "auto-refresh") && tab.mode === "query" && (tab.results?.length ?? 0) > 1) {
if ((intent === "refresh" || intent === "auto-refresh" || intent === "row-identifier-change") && tab.mode === "query" && (tab.results?.length ?? 0) > 1) {
const resultGroupSql = tab.resultBaseSql || tab.lastExecutedSql || tab.sql;
if (!resultGroupSql.trim()) return;
tab.resultSortColumn = undefined;
@@ -487,11 +487,16 @@ export function useDataGridLargeValues(options: UseDataGridLargeValuesOptions) {
}
}
if (requestsByColumn.size === 0) return resolved;
if ((options.databaseType.value !== "mysql" && options.databaseType.value !== "postgres" && options.databaseType.value !== "oracle") || !options.connectionId.value || !options.tableMeta.value?.tableName || options.tableMeta.value.primaryKeys.length === 0) {
throw new Error(options.translate("grid.largeValueNeedsStableKey"));
if (
(options.databaseType.value !== "mysql" && options.databaseType.value !== "postgres" && options.databaseType.value !== "oracle" && options.databaseType.value !== "db2") ||
!options.connectionId.value ||
!options.tableMeta.value?.tableName ||
options.tableMeta.value.primaryKeys.length === 0
) {
throw new Error(options.translate("grid.largeValueNeedsRowIdentifier"));
}
const primaryKeyIndexes = options.tableMeta.value.primaryKeys.map(largeValueSourceColumnIndex);
if (primaryKeyIndexes.some((index) => index < 0)) throw new Error(options.translate("grid.largeValueNeedsStableKey"));
if (primaryKeyIndexes.some((index) => index < 0)) throw new Error(options.translate("grid.largeValueNeedsRowIdentifier"));
for (const [columnIndex, requests] of requestsByColumn) {
for (const chunk of chunkLargeValueRequests(requests)) {
+14 -4
View File
@@ -2661,6 +2661,16 @@ export default {
largeValueColumnUnavailable: "The source column for this large value could not be located.",
largeValueLoadFailed: "Failed to load the full large-field value.",
largeValueNeedsStableKey: "This table has no usable primary key, so the full large-field value cannot be loaded safely. The operation was canceled.",
largeValueNeedsRowIdentifier: "This table has no usable stable row identifier, so the full large-field value cannot be loaded safely. The operation was canceled.",
virtualRowIdentifier: "Virtual Key",
virtualRowIdentifierTitle: "Virtual Unique Key",
virtualRowIdentifierDescription: "Choose the columns DBX should use to identify one row.",
virtualRowIdentifierWarning: "DBX cannot verify this constraint. The selected columns must be unique and should not contain NULL values, or reads, updates, and deletes may affect the wrong rows.",
virtualRowIdentifierApply: "Apply and Reload",
virtualRowIdentifierClear: "Clear",
virtualRowIdentifierApplied: "Virtual unique key saved. Reloading the result.",
virtualRowIdentifierCleared: "Virtual unique key cleared. Reloading the result.",
virtualRowIdentifierSaveFailed: "Could not save the virtual unique key.",
largeValueLoadFailedWithMessage: "Failed to load the full large-field value: {message}",
largeValueLocalSortUnavailable: "This column contains large-value previews and cannot be sorted locally by preview content. Use database sorting instead.",
copyValue: "Copy Value",
@@ -2808,8 +2818,8 @@ export default {
influxDeleteConfirmMessage: "Permanently delete {count} selected point(s) from InfluxDB 1.x? Each predicate uses the exact time and every available tag. Deletes cannot be undone, and an error may occur after earlier points were deleted.",
influxDeleteTarget: "Measurement: {measurement}",
influxDeleteConfirm: "Delete Points",
keylessEditWarning: "No primary key",
keylessEditWarningHint: "This table has no primary key. Updates and deletes use all original row values in the WHERE clause; exact duplicate rows may be affected together.",
keylessEditWarning: "No stable row identifier",
keylessEditWarningHint: "Set a virtual unique key if suitable columns exist. Otherwise, updates and deletes use all original row values in the WHERE clause, and exact duplicate rows may be affected together.",
queryEditReadOnly: "Read-only result",
queryEditUnsupported: {
"not-select": "Only SELECT query results can be edited directly.",
@@ -2820,8 +2830,8 @@ export default {
"complex-source": "DBX could not identify exactly one safe writeback target table for this result.",
"computed-columns": "Computed expressions or function results cannot be written back. Select raw column names instead.",
"no-table": "No editable source table was detected.",
"no-primary-key": "The target table has no primary key, so rows cannot be updated or deleted safely.",
"primary-key-not-returned": "The result is missing the raw primary key column. Include it by its original column name.",
"no-primary-key": "The target table has no stable row identifier, so rows cannot be updated or deleted safely.",
"primary-key-not-returned": "The result is missing a stable row-identifier column. Include it by its original column name.",
"aliased-columns": "Result columns use aliases or expressions. Select editable columns by their original names.",
"metadata-unavailable": "DBX could not load table metadata, so result editing is disabled.",
},
+14 -4
View File
@@ -2596,6 +2596,16 @@ export default withEnglishFallback({
largeValueColumnUnavailable: "无法定位大字段对应的源列。",
largeValueLoadFailed: "读取大字段完整值失败。",
largeValueNeedsStableKey: "该表没有可用的主键,无法安全读取大字段完整值,操作已取消。",
largeValueNeedsRowIdentifier: "该表没有可用的稳定行标识,无法安全读取大字段完整值,操作已取消。",
virtualRowIdentifier: "虚拟唯一键",
virtualRowIdentifierTitle: "设置虚拟唯一键",
virtualRowIdentifierDescription: "选择 DBX 用来唯一定位一行的列。",
virtualRowIdentifierWarning: "DBX 无法验证这个约束。所选列组合必须唯一且不应包含 NULL,否则读取、更新或删除可能命中错误的行。",
virtualRowIdentifierApply: "应用并刷新",
virtualRowIdentifierClear: "清除",
virtualRowIdentifierApplied: "虚拟唯一键已保存,正在刷新结果。",
virtualRowIdentifierCleared: "虚拟唯一键已清除,正在刷新结果。",
virtualRowIdentifierSaveFailed: "无法保存虚拟唯一键。",
largeValueLoadFailedWithMessage: "读取大字段完整值失败:{message}",
largeValueLocalSortUnavailable: "该列包含大字段预览,无法按预览内容进行当前页排序,请使用数据库排序。",
copyValue: "复制值",
@@ -2743,8 +2753,8 @@ export default withEnglishFallback({
influxDeleteConfirmMessage: "确定永久删除所选的 {count} 个 InfluxDB 1.x 数据点吗?每条删除条件都包含精确时间和全部可用标签。删除无法撤销,若中途报错,之前的数据点可能已被删除。",
influxDeleteTarget: "Measurement:{measurement}",
influxDeleteConfirm: "删除数据点",
keylessEditWarning: "无主键",
keylessEditWarningHint: "这张表没有主键。保存更新或删除时会使用整行原始值作为 WHERE 条件;如果存在完全重复的行,可能会影响多行。",
keylessEditWarning: "无稳定行标识",
keylessEditWarningHint: "如果有合适的列,可以设置虚拟唯一键;否则保存更新或删除时会使用整行原始值作为 WHERE 条件,完全重复的行可能被一并影响。",
queryEditReadOnly: "只读结果",
queryEditUnsupported: {
"not-select": "只有 SELECT 查询结果可以直接编辑。",
@@ -2755,8 +2765,8 @@ export default withEnglishFallback({
"complex-source": "无法为此结果唯一确定一个安全的回写目标表。",
"computed-columns": "结果列包含表达式或函数,无法安全回写。请查询原始列名。",
"no-table": "未识别到可编辑的数据表。",
"no-primary-key": "目标表没有主键,无法安全定位要更新或删除的行。",
"primary-key-not-returned": "结果中缺少原始主键列。请把主键列按原列名一起查询出来。",
"no-primary-key": "目标表没有稳定行标识,无法安全定位要更新或删除的行。",
"primary-key-not-returned": "结果中缺少稳定行标识列。请把这些列按原列名一起查询出来。",
"aliased-columns": "结果列使用了别名或表达式。请使用原始列名查询需要编辑的列。",
"metadata-unavailable": "无法读取目标表元数据,暂不能启用结果编辑。",
},
+12 -3
View File
@@ -123,13 +123,22 @@ describe("tableEditing", () => {
});
it("uses unique indexes as row identifiers when primary keys are absent", () => {
expect(editableRowIdentifierColumns("postgres", [column("email"), column("name")], [index(["email", "name"]), index(["email"])])).toEqual(["email"]);
expect(editableRowIdentifierColumns("postgres", [column("email"), column("name")], [index(["email"], true, "email IS NOT NULL")])).toEqual([]);
const columns = [column("email"), column("name")].map((value) => ({ ...value, is_nullable: false }));
expect(editableRowIdentifierColumns("postgres", columns, [index(["email", "name"]), index(["email"])])).toEqual(["email"]);
expect(editableRowIdentifierColumns("postgres", columns, [index(["email"], true, "email IS NOT NULL")])).toEqual([]);
expect(editableRowIdentifierColumns("postgres", [column("id", true), column("email")], [index(["email"])])).toEqual(["id"]);
});
it("rejects nullable, expression, and unresolved unique indexes as row identifiers", () => {
const columns = [column("email"), { ...column("tenant_id"), is_nullable: false }];
expect(editableRowIdentifierColumns("postgres", columns, [index(["email"])])).toEqual([]);
expect(editableRowIdentifierColumns("postgres", columns, [{ ...index(["tenant_id"]), key_is_expression: [true] }])).toEqual([]);
expect(editableRowIdentifierColumns("postgres", columns, [index(["missing"])])).toEqual([]);
});
it.each(["oracle", "oceanbase-oracle"] as const)("prefers physical %s indexes over the ROWID fallback", (databaseType) => {
const columns = [column("OFFER_RELA_ID"), column("ORI_OFFER_ID")];
const columns = [column("OFFER_RELA_ID"), column("ORI_OFFER_ID")].map((value) => ({ ...value, is_nullable: false }));
const primaryIndex = { ...index(["OFFER_RELA_ID"], false), is_primary: true };
expect(editableRowIdentifierColumns(databaseType, columns, [index(["ORI_OFFER_ID"]), primaryIndex], "TABLE")).toEqual(["OFFER_RELA_ID"]);
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { loadVirtualRowIdentifier, removeVirtualRowIdentifier, saveVirtualRowIdentifier, virtualRowIdentifierScopeKey } from "@/lib/table/virtualRowIdentifier";
import type { ColumnInfo } from "@/types/database";
const scope = {
connectionId: "db2-1",
database: "MAXIMO",
schema: "APP",
tableName: "MAFAPPDATA",
};
function column(name: string): ColumnInfo {
return { name, data_type: "VARCHAR", is_nullable: true, column_default: null, is_primary_key: false, extra: null };
}
describe("virtualRowIdentifier", () => {
beforeEach(() => localStorage.clear());
it("persists a composite key for one exact table scope", () => {
const columns = [column("TENANT_ID"), column("APP_ID"), column("APP")];
expect(saveVirtualRowIdentifier(scope, ["TENANT_ID", "APP_ID"], columns)).toBe(true);
expect(loadVirtualRowIdentifier(scope, columns)).toEqual(["TENANT_ID", "APP_ID"]);
expect(loadVirtualRowIdentifier({ ...scope, tableName: "OTHER_TABLE" }, columns)).toEqual([]);
expect(virtualRowIdentifierScopeKey(scope)).not.toBe(virtualRowIdentifierScopeKey({ ...scope, catalog: "ARCHIVE" }));
});
it("canonicalizes unambiguous casing and removes a key when a column disappears", () => {
expect(saveVirtualRowIdentifier(scope, ["app_id"], [column("app_id")])).toBe(true);
expect(loadVirtualRowIdentifier(scope, [column("APP_ID")])).toEqual(["APP_ID"]);
expect(loadVirtualRowIdentifier(scope, [column("OTHER_ID")])).toEqual([]);
expect(loadVirtualRowIdentifier(scope, [column("APP_ID")])).toEqual([]);
});
it("rejects empty, duplicate, and unknown column selections", () => {
const columns = [column("ID"), column("NAME")];
expect(saveVirtualRowIdentifier(scope, [], columns)).toBe(false);
expect(saveVirtualRowIdentifier(scope, ["ID", "ID"], columns)).toBe(false);
expect(saveVirtualRowIdentifier(scope, ["MISSING"], columns)).toBe(false);
expect(loadVirtualRowIdentifier(scope, columns)).toEqual([]);
});
it("clears a persisted key", () => {
const columns = [column("ID")];
expect(saveVirtualRowIdentifier(scope, ["ID"], columns)).toBe(true);
removeVirtualRowIdentifier(scope);
expect(loadVirtualRowIdentifier(scope, columns)).toEqual([]);
});
});
@@ -32,7 +32,7 @@ export function isDataGridToolbarActionCompact(action: DataGridToolbarActionKey,
return actionIndex < Math.max(0, Math.floor(compactActionCount));
}
export type DataGridReloadIntent = "refresh" | "auto-refresh";
export type DataGridReloadIntent = "refresh" | "auto-refresh" | "row-identifier-change";
export interface DataGridToolbarActionCapability {
label: string;
@@ -1,6 +1,7 @@
import type { ColumnInfo, DatabaseType, IndexInfo, QueryTab } from "@/types/database";
import * as api from "@/lib/backend/api";
import { editableRowIdentifierColumns, physicalTablePrimaryKeys } from "@/lib/table/tableEditing";
import { loadVirtualRowIdentifier } from "@/lib/table/virtualRowIdentifier";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadCacheStatus, type MetadataLoadTraceLogger } from "./metadataLoadCoordinator";
import { metadataScopeKey, metadataScopeParts, type MetadataScopeInput } from "./metadataLoadScope";
import { metadataCacheInvalidationMatcher, MetadataResultCache, type MetadataCacheInvalidation } from "./metadataResultCache";
@@ -17,6 +18,7 @@ export interface TableMetadata {
columns: ColumnInfo[];
indexes: IndexInfo[];
primaryKeys: string[];
virtualPrimaryKeys?: string[];
rowIdentityResolved?: boolean;
cachedAt: number;
}
@@ -167,10 +169,17 @@ export function tableMetadataScope(request: Pick<TableMetadataRequest, "connecti
};
}
function withVirtualRowIdentifier(metadata: TableMetadata, request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "catalog">): TableMetadata {
if (metadata.primaryKeys.length > 0) return metadata;
const virtualPrimaryKeys = loadVirtualRowIdentifier(request, metadata.columns);
if (virtualPrimaryKeys.length === 0) return metadata;
return { ...metadata, primaryKeys: virtualPrimaryKeys, virtualPrimaryKeys, rowIdentityResolved: true };
}
export function getCachedTableMetadata(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): TableMetadataLoadResult | undefined {
const hit = tableMetadataCache.get(tableMetadataScope(request));
if (!hit) return undefined;
return { metadata: hit.value, cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs };
return { metadata: withVirtualRowIdentifier(hit.value, request), cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs };
}
export function updateCachedTableMetadataType(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">, tableType: string): boolean {
@@ -275,6 +284,7 @@ export function tableMetadataToDataTabMeta(metadata: TableMetadata, overrides?:
database: metadata.database,
columns: metadata.columns,
primaryKeys: metadata.primaryKeys,
...(metadata.virtualPrimaryKeys?.length ? { virtualPrimaryKeys: metadata.virtualPrimaryKeys } : {}),
physicalPrimaryKeys: physicalTablePrimaryKeys(metadata.columns, metadata.indexes),
};
}
@@ -310,7 +320,7 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
resultCount: cached.value.columns.length,
stale: cached.stale,
});
return { metadata: cached.value, cacheStatus: cached.stale ? "stale" : "hit", ageMs: cached.ageMs };
return { metadata: withVirtualRowIdentifier(cached.value, request), cacheStatus: cached.stale ? "stale" : "hit", ageMs: cached.ageMs };
}
}
@@ -372,7 +382,7 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
resultCount: metadata.columns.length,
force: request.force === true,
});
return { metadata, cacheStatus: request.force ? "refresh" : "miss", ageMs: 0 };
return { metadata: withVirtualRowIdentifier(metadata, request), cacheStatus: request.force ? "refresh" : "miss", ageMs: 0 };
}
export function invalidateTableMetadataCache(match: MetadataCacheInvalidation): number {
+8 -2
View File
@@ -40,8 +40,14 @@ export function editableRowIdentifierColumns(databaseType: DatabaseType | undefi
const primaryKeys = editablePrimaryKeys(databaseType, columns, tableType);
const oracleRowIdFallback = getDatabaseCapability(databaseType).syntheticKey === "oracle-rowid" && primaryKeys.length === 1 && primaryKeys[0]?.toUpperCase() === DBX_ROWID_COLUMN;
if (primaryKeys.length > 0 && !oracleRowIdFallback) return primaryKeys;
const uniqueIndex = indexes?.filter((index) => !index.filter && index.columns.length > 0 && (index.is_primary || index.is_unique)).sort((left, right) => Number(right.is_primary) - Number(left.is_primary) || left.columns.length - right.columns.length)[0];
return uniqueIndex?.columns ?? primaryKeys;
const columnByName = new Map(columns.map((column) => [column.name.toLowerCase(), column]));
const uniqueIndex = indexes
?.filter((index) => {
if (index.filter?.trim() || index.columns.length === 0 || (!index.is_primary && !index.is_unique) || index.key_is_expression?.some(Boolean)) return false;
return index.columns.every((name) => columnByName.get(name.toLowerCase())?.is_nullable === false);
})
.sort((left, right) => Number(right.is_primary) - Number(left.is_primary) || left.columns.length - right.columns.length)[0];
return uniqueIndex?.columns.map((name) => columnByName.get(name.toLowerCase())!.name) ?? primaryKeys;
}
export function isTableDataEditable(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
@@ -0,0 +1,74 @@
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import type { ColumnInfo } from "@/types/database";
const STORAGE_PREFIX = "dbx-table-virtual-row-identifier:";
const STORAGE_VERSION = 1;
export interface VirtualRowIdentifierScope {
connectionId: string;
database: string;
catalog?: string;
schema?: string;
tableName: string;
}
interface StoredVirtualRowIdentifier {
version: number;
columns: string[];
}
export function virtualRowIdentifierScopeKey(scope: VirtualRowIdentifierScope): string {
return JSON.stringify([scope.connectionId, scope.database, scope.catalog ?? "", scope.schema ?? "", scope.tableName]);
}
export function resolveRowIdentifierColumns(requestedColumns: readonly string[], availableColumns: readonly Pick<ColumnInfo, "name">[]): string[] | null {
if (requestedColumns.length === 0 || availableColumns.length === 0) return null;
const resolved: string[] = [];
for (const requested of requestedColumns) {
const exact = availableColumns.find((column) => column.name === requested);
const foldedMatches = exact ? [] : availableColumns.filter((column) => column.name.toLowerCase() === requested.toLowerCase());
const canonical = exact?.name ?? (foldedMatches.length === 1 ? foldedMatches[0]!.name : undefined);
if (!canonical || resolved.includes(canonical)) return null;
resolved.push(canonical);
}
return resolved;
}
export function loadVirtualRowIdentifier(scope: VirtualRowIdentifierScope, availableColumns: readonly Pick<ColumnInfo, "name">[]): string[] {
if (availableColumns.length === 0) return [];
const storageKey = `${STORAGE_PREFIX}${virtualRowIdentifierScopeKey(scope)}`;
const raw = safeLocalStorageGet(storageKey);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Partial<StoredVirtualRowIdentifier>;
if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.columns) || !parsed.columns.every((column) => typeof column === "string")) {
safeLocalStorageRemove(storageKey);
return [];
}
const columns = resolveRowIdentifierColumns(parsed.columns, availableColumns);
if (!columns) {
safeLocalStorageRemove(storageKey);
return [];
}
if (columns.some((column, index) => column !== parsed.columns![index])) {
safeLocalStorageSet(storageKey, JSON.stringify({ version: STORAGE_VERSION, columns } satisfies StoredVirtualRowIdentifier));
}
return columns;
} catch {
safeLocalStorageRemove(storageKey);
return [];
}
}
export function saveVirtualRowIdentifier(scope: VirtualRowIdentifierScope, selectedColumns: readonly string[], availableColumns: readonly Pick<ColumnInfo, "name">[]): boolean {
const columns = resolveRowIdentifierColumns(selectedColumns, availableColumns);
if (!columns) return false;
return safeLocalStorageSet(`${STORAGE_PREFIX}${virtualRowIdentifierScopeKey(scope)}`, JSON.stringify({ version: STORAGE_VERSION, columns } satisfies StoredVirtualRowIdentifier));
}
export function removeVirtualRowIdentifier(scope: VirtualRowIdentifierScope): void {
safeLocalStorageRemove(`${STORAGE_PREFIX}${virtualRowIdentifierScopeKey(scope)}`);
}
@@ -1,5 +1,13 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { saveVirtualRowIdentifier } from "@/lib/table/virtualRowIdentifier";
const localStorageValues = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => localStorageValues.get(key) ?? null,
setItem: (key: string, value: string) => localStorageValues.set(key, value),
removeItem: (key: string) => localStorageValues.delete(key),
});
const executeMulti = vi.fn();
const executeQuery = vi.fn();
@@ -88,6 +96,7 @@ function queryAnalysis(sql: string) {
describe("queryStore hidden primary key editing", () => {
beforeEach(async () => {
vi.clearAllMocks();
localStorageValues.clear();
const { clearTableMetadataCache } = await import("@/lib/metadata/tableMetadataCache");
clearTableMetadataCache();
setActivePinia(createPinia());
@@ -1356,13 +1365,13 @@ describe("queryStore hidden primary key editing", () => {
expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", 'SELECT PAYLOAD, "ID" AS "__DBX_PK_0" FROM APP.DOCUMENTS', undefined, expect.any(String), expect.objectContaining({ tableDataPreview: true, timeoutSecs: 30 }));
});
it("defers DB2 BLOB materialization for a stable-key star query", async () => {
it("defers DB2 BLOB materialization for a non-null unique-index star query", async () => {
getConnectionConfig.mockReturnValue({ id: "db2-1", name: "DB2", db_type: "db2", database: "MAXIMO", query_timeout_secs: 60 });
getColumns.mockResolvedValue([
{ name: "MAFAPPDATAID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "MAFAPPDATAID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "APP", data_type: "BLOB", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
]);
listIndexes.mockResolvedValue([{ name: "MAFAPPDATA_PK", columns: ["MAFAPPDATAID"], is_unique: true, is_primary: true }]);
listIndexes.mockResolvedValue([{ name: "MAFAPPDATA_UQ", columns: ["MAFAPPDATAID"], is_unique: true, is_primary: false }]);
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
@@ -1394,6 +1403,46 @@ describe("queryStore hidden primary key editing", () => {
expect(store.tabs.find((tab) => tab.id === tabId)?.result?.large_value_cells).toEqual([{ row_index: 0, column_index: 1, original_bytes: 64 * 1024 * 1024 }]);
});
it("defers DB2 BLOB materialization through a persisted virtual unique key", async () => {
getConnectionConfig.mockReturnValue({ id: "db2-1", name: "DB2", db_type: "db2", database: "MAXIMO", query_timeout_secs: 60 });
const columns = [
{ name: "MAFAPPDATAID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "APP", data_type: "BLOB", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
];
getColumns.mockResolvedValue(columns);
listIndexes.mockResolvedValue([]);
expect(saveVirtualRowIdentifier({ connectionId: "db2-1", database: "MAXIMO", schema: "MAXIMO", tableName: "MAFAPPDATA" }, ["MAFAPPDATAID"], columns)).toBe(true);
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "MAFAPPDATA",
tableAlias: "mf",
selectStar: true,
columns: [],
},
});
executeMulti.mockResolvedValue([
{
columns: ["MAFAPPDATAID", "APP"],
rows: [[1, "<BLOB>"]],
affected_rows: 0,
execution_time_ms: 57,
large_value_cells: [{ row_index: 0, column_index: 1, original_bytes: 64 * 1024 * 1024 }],
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("db2-1", "MAXIMO", "Query", "query", "MAXIMO");
store.setAutoCommit(tabId, true);
await store.executeTabSql(tabId, "select * from MAFAPPDATA mf");
expect(executeMulti).toHaveBeenCalledWith("db2-1", "MAXIMO", "select * from MAFAPPDATA mf", "MAXIMO", expect.any(String), expect.objectContaining({ tableDataPreview: true, timeoutSecs: 60 }));
await vi.waitFor(() => expect(store.tabs.find((tab) => tab.id === tabId)?.tableMeta?.virtualPrimaryKeys).toEqual(["MAFAPPDATAID"]));
});
it("keeps deferred DB2 LOB handling disabled without a stable key", async () => {
getConnectionConfig.mockReturnValue({ id: "db2-1", name: "DB2", db_type: "db2", database: "MAXIMO", query_timeout_secs: 60 });
getColumns.mockResolvedValue([{ name: "APP", data_type: "BLOB", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]);
@@ -1830,16 +1879,25 @@ describe("queryStore hidden primary key editing", () => {
expect(tab.result?.hidden_column_indexes).toBeUndefined();
});
it("does not hide a unique index when the table has no declared primary key", async () => {
it("injects a non-null unique index when the table has no declared primary key", async () => {
getColumns.mockResolvedValue([
{ name: "email", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
]);
listIndexes.mockResolvedValue([{ name: "uq_users_email", columns: ["email"], is_unique: true, is_primary: false }]);
analyzeEditableQueryEditability.mockImplementation(async (sql: string) => ({
editable: true,
analysis: {
schema: undefined,
tableName: "users",
selectStar: false,
columns: [{ sourceName: "name", resultName: "name", expression: "name" }, ...(sql.includes("__DBX_PK_0") ? [{ sourceName: "email", resultName: "__DBX_PK_0", expression: "`email`" }] : [])],
},
}));
executeMulti.mockResolvedValue([
{
columns: ["name"],
rows: [["Alice"]],
columns: ["name", "__DBX_PK_0"],
rows: [["Alice", "alice@example.com"]],
affected_rows: 0,
execution_time_ms: 1,
},
@@ -1851,10 +1909,11 @@ describe("queryStore hidden primary key editing", () => {
await store.executeTabSql(tabId, "SELECT name FROM users");
expect(executeMulti).toHaveBeenCalledWith("mysql-1", "app", "SELECT name FROM users", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 }));
expect(executeMulti).toHaveBeenCalledWith("mysql-1", "app", "SELECT name, `email` 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.queryEditabilityReason).toBe("primary-key-not-returned"));
expect(tab.result?.hidden_column_indexes).toBeUndefined();
expect(tab.result?.hidden_column_indexes).toEqual([1]);
await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["name", "email"]));
expect(tab.queryEditabilityReason).toBeUndefined();
});
it("hides returned internal keys but remains read-only when another hidden key is missing", async () => {
+12 -5
View File
@@ -77,7 +77,7 @@ import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { formatRedisConsoleValue } from "@/lib/redis/redisValuePresentation";
import { usesAgentCursorForQuery, usesAgentCursorForTableData } from "@/lib/database/databaseDriverManifest";
import { connectionIsDorisFamilyCatalogCapable, defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
import { repairRestoredDataTabTableIdentity, tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy";
@@ -4768,7 +4768,14 @@ export const useQueryStore = defineStore("query", () => {
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
sourceView: original.sourceView,
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
tableMeta: original.tableMeta
? {
...original.tableMeta,
columns: [...original.tableMeta.columns],
primaryKeys: [...original.tableMeta.primaryKeys],
...(original.tableMeta.virtualPrimaryKeys ? { virtualPrimaryKeys: [...original.tableMeta.virtualPrimaryKeys] } : {}),
}
: undefined,
tableMetaGeneration: original.mode === "data" ? original.tableMetaGeneration : undefined,
tableMetaUpdatedAt: original.mode === "data" ? original.tableMetaUpdatedAt : undefined,
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
@@ -6126,6 +6133,7 @@ export const useQueryStore = defineStore("query", () => {
tableType: metadata.tableType,
columns: metadata.columns,
primaryKeys: metadata.primaryKeys,
...(metadata.virtualPrimaryKeys?.length ? { virtualPrimaryKeys: metadata.virtualPrimaryKeys } : {}),
},
};
}
@@ -6334,14 +6342,13 @@ export const useQueryStore = defineStore("query", () => {
}
if (loaded.tableMeta.columns.length === 0) return unchanged;
if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged;
const columnPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name);
const primaryKeys = databaseType === "oracle" ? loaded.tableMeta.primaryKeys : editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType);
const primaryKeys = loaded.tableMeta.primaryKeys;
const syntheticRowId = (databaseType === "oracle" || databaseType === "xugu") && usesSyntheticRowIdKey(databaseType, primaryKeys, loaded.tableMeta.tableType);
// Base tables without a natural identifier use the same ROWID identity
// as table-data tabs (Oracle and Xugu). Confirm the object is a base
// table because selecting ROWID from a view can fail with ORA-01445.
if (syntheticRowId && !(await resolveOracleRowIdSafety(tab, loaded, databaseType))) return unchanged;
const declaredPrimaryKeys = databaseType === "oracle" && !syntheticRowId ? primaryKeys : columnPrimaryKeys;
const declaredPrimaryKeys = syntheticRowId ? [] : primaryKeys;
return buildHiddenPrimaryKeyPreparation(tab, sql, databaseType, loaded, primaryKeys, declaredPrimaryKeys, traceId, elapsed);
} catch (error) {
// Metadata enrichment is optional. Query execution must retain its prior
+2
View File
@@ -2220,6 +2220,8 @@ export interface QueryTab {
database?: string;
columns: ColumnInfo[];
primaryKeys: string[];
/** User-declared row identifier columns, used only when no automatic stable identifier exists. */
virtualPrimaryKeys?: string[];
/** Physical primary keys used for table-open default sorting; excludes unique and synthetic row identifiers. */
physicalPrimaryKeys?: string[];
};