mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
@@ -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)) {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user