feat(transfer): support cross-database table paste
This commit is contained in:
1 parent
d928412bf6
commit
551f696cd5
14 files changed
+309
-43
No files matched your search
@@ -174,7 +174,16 @@ watch(
|
||||
@update:open="emit('update:showSqlParameterDialog', $event)"
|
||||
@execute="emit('sqlParametersConfirm', $event)"
|
||||
/>
|
||||
<DataTransferDialog v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
|
||||
<DataTransferDialog
|
||||
v-model:open="dialogs.showTransferDialog.value"
|
||||
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.transferPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.transferPrefillSchema.value"
|
||||
:prefill-tables="dialogs.transferPrefillTables.value"
|
||||
:prefill-target-connection-id="dialogs.transferPrefillTargetConnectionId.value"
|
||||
:prefill-target-database="dialogs.transferPrefillTargetDatabase.value"
|
||||
:prefill-target-schema="dialogs.transferPrefillTargetSchema.value"
|
||||
/>
|
||||
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
|
||||
<DataCompareDialog
|
||||
v-if="dialogs.showDataCompareDialog.value"
|
||||
|
||||
@@ -56,7 +56,7 @@ import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDia
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
|
||||
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
|
||||
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
|
||||
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||||
@@ -79,7 +79,7 @@ import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRe
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
|
||||
import { formatSqlInsert } from "@/lib/export/exportFormats";
|
||||
import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport";
|
||||
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
|
||||
@@ -1922,10 +1922,19 @@ function normalizedObjectBrowserTableClipboardEntries() {
|
||||
if (clipboard?.kind !== "table-copy") return [];
|
||||
return clipboard.tables.map((entry) => ({
|
||||
...entry,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database),
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId),
|
||||
}));
|
||||
}
|
||||
|
||||
function canTransferTableClipboard(): boolean {
|
||||
const entries = normalizedObjectBrowserTableClipboardEntries();
|
||||
const target = pasteTableTargetContext();
|
||||
if (entries.length === 0 || props.connection.read_only) return false;
|
||||
const source = tableClipboardSourceContext(entries);
|
||||
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
|
||||
return !!source && !!sourceConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(props.connection.db_type) && !tableClipboardMatchesTarget(entries, target);
|
||||
}
|
||||
|
||||
function pasteTableTargetContext(): TableClipboardContext {
|
||||
return {
|
||||
connectionId: props.connection.id,
|
||||
@@ -1934,9 +1943,29 @@ function pasteTableTargetContext(): TableClipboardContext {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObjectBrowserTableClipboardSchema(schema?: string, database = props.database): string | undefined {
|
||||
if (!isSchemaAware(effectiveDatabaseType.value) && effectiveDatabaseType.value !== "sqlite") return undefined;
|
||||
return connectionObjectTreeNodeSchema(props.connection, database, schema);
|
||||
function normalizeObjectBrowserTableClipboardSchema(schema?: string, database = props.database, connectionId = props.connection.id): string | undefined {
|
||||
const connection = connectionStore.getConfig(connectionId) ?? props.connection;
|
||||
if (!isSchemaAware(connection.db_type) && connection.db_type !== "sqlite") return undefined;
|
||||
return connectionObjectTreeNodeSchema(connection, database, schema);
|
||||
}
|
||||
|
||||
function openTransferFromTableClipboard(): boolean {
|
||||
const clipboard = connectionStore.treeClipboard;
|
||||
const entries = normalizedObjectBrowserTableClipboardEntries();
|
||||
const target = pasteTableTargetContext();
|
||||
if (clipboard?.kind !== "table-copy") return false;
|
||||
const source = tableClipboardSourceContext(entries);
|
||||
if (!source || tableClipboardMatchesTarget(entries, target)) return false;
|
||||
connectionStore.transferSource = {
|
||||
connectionId: source.connectionId,
|
||||
database: source.database,
|
||||
schema: source.schema ?? undefined,
|
||||
tables: clipboard.tables.map((entry) => entry.tableName),
|
||||
targetConnectionId: target.connectionId,
|
||||
targetDatabase: target.database,
|
||||
targetSchema: target.schema ?? undefined,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
function copySingleTableToClipboard(row: ObjectBrowserRow) {
|
||||
@@ -1956,6 +1985,10 @@ function copySingleTableToClipboard(row: ObjectBrowserRow) {
|
||||
|
||||
function openPasteTableDialog() {
|
||||
const clipboard = connectionStore.treeClipboard;
|
||||
if (canTransferTableClipboard()) {
|
||||
openTransferFromTableClipboard();
|
||||
return;
|
||||
}
|
||||
if (!canPasteTableClipboard() || clipboard?.kind !== "table-copy") {
|
||||
toast(t("contextMenu.noTableToPaste"), 2000);
|
||||
return;
|
||||
@@ -1964,7 +1997,7 @@ function openPasteTableDialog() {
|
||||
pasteTableEntries.value = clipboard.tables.map((entry) => ({
|
||||
sourceName: entry.tableName,
|
||||
targetName: `${entry.tableName}_copy`,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database),
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId),
|
||||
}));
|
||||
showPasteDialog.value = true;
|
||||
}
|
||||
@@ -1979,7 +2012,7 @@ function onObjectBrowserKeydown(event: KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
if (eventTargetAllowsAppClipboardShortcut(event, "v")) {
|
||||
if (!canPasteTableClipboard()) return;
|
||||
if (!canPasteTableClipboard() && !canTransferTableClipboard()) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openPasteTableDialog();
|
||||
@@ -2469,12 +2502,16 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
|
||||
}
|
||||
|
||||
function objectBrowserTableClipboardMenuState(item: ObjectBrowserRow) {
|
||||
return tableClipboardMenuState(normalizedObjectBrowserTableClipboardEntries(), {
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(item.schema || selectedSchema.value),
|
||||
tableName: item.name,
|
||||
});
|
||||
return tableClipboardMenuState(
|
||||
normalizedObjectBrowserTableClipboardEntries(),
|
||||
{
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema: normalizeObjectBrowserTableClipboardSchema(item.schema || selectedSchema.value),
|
||||
tableName: item.name,
|
||||
},
|
||||
canTransferTableClipboard(),
|
||||
);
|
||||
}
|
||||
|
||||
function tableClipboardMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
|
||||
@@ -14,11 +14,11 @@ describe("ObjectBrowser table clipboard context menu", () => {
|
||||
});
|
||||
|
||||
it("normalizes copied and target schemas before paste validation", () => {
|
||||
expect(objectBrowserSource).toMatch(/function normalizedObjectBrowserTableClipboardEntries\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database\)/);
|
||||
expect(objectBrowserSource).toMatch(/function normalizedObjectBrowserTableClipboardEntries\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database, entry\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/function canPasteTableClipboard\(\)[\s\S]*?tableClipboardMatchesTarget\(normalizedObjectBrowserTableClipboardEntries\(\), pasteTableTargetContext\(\)\)/);
|
||||
expect(objectBrowserSource).toMatch(/function pasteTableTargetContext\(\)[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(selectedSchema\.value\)/);
|
||||
expect(objectBrowserSource).toMatch(/pasteTableEntries\.value = clipboard\.tables\.map[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database\)/);
|
||||
expect(objectBrowserSource).toMatch(/function normalizeObjectBrowserTableClipboardSchema[\s\S]*?!isSchemaAware\(effectiveDatabaseType\.value\)[\s\S]*?effectiveDatabaseType\.value !== "sqlite"[\s\S]*?return undefined/);
|
||||
expect(objectBrowserSource).toMatch(/pasteTableEntries\.value = clipboard\.tables\.map[\s\S]*?normalizeObjectBrowserTableClipboardSchema\(entry\.schema, entry\.database, entry\.connectionId\)/);
|
||||
expect(objectBrowserSource).toMatch(/function normalizeObjectBrowserTableClipboardSchema[\s\S]*?connectionStore\.getConfig\(connectionId\) \?\? props\.connection[\s\S]*?!isSchemaAware\(connection\.db_type\)[\s\S]*?connection\.db_type !== "sqlite"[\s\S]*?return undefined/);
|
||||
});
|
||||
|
||||
it("consumes only the clipboard used by a fully successful paste", () => {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("ObjectBrowser cross-database table paste", () => {
|
||||
it("routes a foreign table clipboard to the shared data-transfer dialog", () => {
|
||||
expect(objectBrowserSource).toContain("function canTransferTableClipboard()");
|
||||
expect(objectBrowserSource).toContain("function openTransferFromTableClipboard()");
|
||||
expect(objectBrowserSource).toContain("targetConnectionId: target.connectionId");
|
||||
expect(objectBrowserSource).toContain("targetDatabase: target.database");
|
||||
expect(objectBrowserSource).toMatch(/function objectBrowserTableClipboardMenuState[\s\S]*?tableClipboardMenuState\([\s\S]*?canTransferTableClipboard\(\),\s*\)/);
|
||||
});
|
||||
|
||||
it("keeps the local duplicate-table paste path available", () => {
|
||||
expect(objectBrowserSource).toContain("if (canTransferTableClipboard()) {");
|
||||
expect(objectBrowserSource).toContain("if (!canPasteTableClipboard() && !canTransferTableClipboard()) return;");
|
||||
expect(objectBrowserSource).toContain("pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);");
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
supportsTableImport,
|
||||
supportsTableTruncate,
|
||||
supportsTableStructureEditing,
|
||||
supportsTransfer,
|
||||
usesTreeSchemaMode,
|
||||
} from "@/lib/database/databaseCapabilities";
|
||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
@@ -126,7 +127,7 @@ import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFor
|
||||
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
|
||||
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard";
|
||||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard";
|
||||
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection";
|
||||
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
|
||||
import { connectionSupportsDatabaseUserAdmin, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
|
||||
@@ -855,6 +856,35 @@ function canPasteTreeClipboardToCurrentNode(): boolean {
|
||||
return tableClipboardMatchesTarget(normalizedTreeClipboardTableEntries(), pasteTableTargetContext());
|
||||
}
|
||||
|
||||
function canTransferTreeClipboardToCurrentNode(): boolean {
|
||||
const entries = normalizedTreeClipboardTableEntries();
|
||||
const target = pasteTableTargetContext();
|
||||
if (!target || entries.length === 0) return false;
|
||||
const source = tableClipboardSourceContext(entries);
|
||||
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
|
||||
const targetConfig = connectionStore.getConfig(target.connectionId);
|
||||
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !targetConfig.read_only && !tableClipboardMatchesTarget(entries, target);
|
||||
}
|
||||
|
||||
function openTransferFromTreeClipboard(): boolean {
|
||||
const clipboard = connectionStore.treeClipboard;
|
||||
const entries = normalizedTreeClipboardTableEntries();
|
||||
const target = pasteTableTargetContext();
|
||||
if (clipboard?.kind !== "table-copy" || !target) return false;
|
||||
const source = tableClipboardSourceContext(entries);
|
||||
if (!source || tableClipboardMatchesTarget(entries, target)) return false;
|
||||
connectionStore.transferSource = {
|
||||
connectionId: source.connectionId,
|
||||
database: source.database,
|
||||
schema: source.schema ?? undefined,
|
||||
tables: clipboard.tables.map((entry) => entry.tableName),
|
||||
targetConnectionId: target.connectionId,
|
||||
targetDatabase: target.database,
|
||||
targetSchema: target.schema ?? undefined,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
function requestPasteTreeClipboard(): boolean {
|
||||
claimTreeItemDialogOwnership();
|
||||
ensureDangerDialogRouting();
|
||||
@@ -870,14 +900,16 @@ function requestPasteTreeClipboard(): boolean {
|
||||
.catch((e: any) => toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000));
|
||||
return true;
|
||||
}
|
||||
if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) return false;
|
||||
if (clipboard?.kind !== "table-copy") return false;
|
||||
if (canTransferTreeClipboardToCurrentNode()) return openTransferFromTreeClipboard();
|
||||
if (!canPasteTreeClipboardToCurrentNode()) return false;
|
||||
pasteTableMode.value = defaultPasteTableMode(currentDatabaseType());
|
||||
pasteTableEntries.value = clipboard.tables.map((entry) => ({
|
||||
sourceName: entry.tableName,
|
||||
targetName: `${entry.tableName}_copy`,
|
||||
connectionId: entry.connectionId,
|
||||
database: entry.database,
|
||||
schema: entry.schema,
|
||||
schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema),
|
||||
}));
|
||||
showPasteDialog.value = true;
|
||||
return true;
|
||||
@@ -3044,7 +3076,15 @@ async function confirmPasteTable() {
|
||||
|
||||
function openPasteTableDialog() {
|
||||
const clipboard = connectionStore.treeClipboard;
|
||||
if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) {
|
||||
if (clipboard?.kind !== "table-copy") {
|
||||
toast(t("contextMenu.noTableToPaste"), 2000);
|
||||
return;
|
||||
}
|
||||
if (canTransferTreeClipboardToCurrentNode()) {
|
||||
openTransferFromTreeClipboard();
|
||||
return;
|
||||
}
|
||||
if (!canPasteTreeClipboardToCurrentNode()) {
|
||||
toast(t("contextMenu.noTableToPaste"), 2000);
|
||||
return;
|
||||
}
|
||||
@@ -3054,7 +3094,7 @@ function openPasteTableDialog() {
|
||||
targetName: `${entry.tableName}_copy`,
|
||||
connectionId: entry.connectionId,
|
||||
database: entry.database,
|
||||
schema: entry.schema,
|
||||
schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema),
|
||||
}));
|
||||
showPasteDialog.value = true;
|
||||
}
|
||||
@@ -4374,12 +4414,16 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
function treeTableClipboardMenuItems(node: TreeNode): ContextMenuItem[] {
|
||||
const copyItem: ContextMenuItem = { label: t("contextMenu.copyTable"), action: copySelectedNames, icon: Copy };
|
||||
if (!node.connectionId || !node.database) return [copyItem];
|
||||
const state = tableClipboardMenuState(normalizedTreeClipboardTableEntries(), {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: normalizeTreeClipboardSchema(node.connectionId, node.database, node.schema),
|
||||
tableName: node.label,
|
||||
});
|
||||
const state = tableClipboardMenuState(
|
||||
normalizedTreeClipboardTableEntries(),
|
||||
{
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: normalizeTreeClipboardSchema(node.connectionId, node.database, node.schema),
|
||||
tableName: node.label,
|
||||
},
|
||||
canTransferTreeClipboardToCurrentNode(),
|
||||
);
|
||||
if (state === "copy") return [copyItem];
|
||||
const pasteItem: ContextMenuItem = { label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard };
|
||||
return state === "paste" ? [pasteItem] : [copyItem, pasteItem];
|
||||
@@ -4397,7 +4441,7 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
|
||||
if (canOpenTableImport.value) {
|
||||
items.push({ label: t("contextMenu.importData"), action: openTableImport, icon: Upload });
|
||||
}
|
||||
if (canPasteTreeClipboardToCurrentNode()) {
|
||||
if (canPasteTreeClipboardToCurrentNode() || canTransferTreeClipboardToCurrentNode()) {
|
||||
items.push({ label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard });
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const runtimeSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("cross-database table paste", () => {
|
||||
it("routes a table clipboard from another context to data transfer", () => {
|
||||
expect(runtimeSource).toContain("function canTransferTreeClipboardToCurrentNode()");
|
||||
expect(runtimeSource).toContain("function openTransferFromTreeClipboard()");
|
||||
expect(runtimeSource).toContain("targetConnectionId: target.connectionId");
|
||||
expect(runtimeSource).toContain("targetDatabase: target.database");
|
||||
expect(runtimeSource).toContain("tables: clipboard.tables.map((entry) => entry.tableName)");
|
||||
expect(runtimeSource).toMatch(/function treeTableClipboardMenuItems[\s\S]*?tableClipboardMenuState\([\s\S]*?canTransferTreeClipboardToCurrentNode\(\),\s*\)/);
|
||||
});
|
||||
|
||||
it("retains the local duplicate-table paste path", () => {
|
||||
expect(runtimeSource).toContain("if (canTransferTreeClipboardToCurrentNode()) return openTransferFromTreeClipboard();");
|
||||
expect(runtimeSource).toContain("pasteTableMode.value = defaultPasteTableMode(currentDatabaseType());");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,11 @@ const open = defineModel<boolean>("open", { default: false });
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTables?: string[];
|
||||
prefillTargetConnectionId?: string;
|
||||
prefillTargetDatabase?: string;
|
||||
prefillTargetSchema?: string;
|
||||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
@@ -42,6 +47,8 @@ const sourceTables = ref<string[]>([]);
|
||||
const selectedTables = ref<Set<string>>(new Set());
|
||||
const tableSearch = ref("");
|
||||
const loadingTables = ref(false);
|
||||
const pendingSourceSchemaPrefill = ref("");
|
||||
const pendingSelectedTablesPrefill = ref<string[] | null>(null);
|
||||
|
||||
// Target state
|
||||
const targetConnectionId = ref("");
|
||||
@@ -49,6 +56,7 @@ const targetDatabase = ref("");
|
||||
const targetDatabases = ref<string[]>([]);
|
||||
const targetSchemas = ref<string[]>([]);
|
||||
const targetSchema = ref("");
|
||||
const pendingTargetSchemaPrefill = ref("");
|
||||
|
||||
// Options
|
||||
const createTable = ref(true);
|
||||
@@ -77,7 +85,12 @@ function isMongoConnection(id: string): boolean {
|
||||
return connectionType(id) === "mongodb";
|
||||
}
|
||||
|
||||
const canStart = computed(() => sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value && selectedTables.value.size > 0 && sourceConnectionId.value + sourceDatabase.value !== targetConnectionId.value + targetDatabase.value);
|
||||
const canStart = computed(() => {
|
||||
const effectiveSourceSchema = sourceSchema.value || sourceDatabase.value;
|
||||
const effectiveTargetSchema = targetSchema.value || targetDatabase.value;
|
||||
const sameSourceAndTarget = sourceConnectionId.value === targetConnectionId.value && sourceDatabase.value === targetDatabase.value && effectiveSourceSchema === effectiveTargetSchema;
|
||||
return sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value && selectedTables.value.size > 0 && !sameSourceAndTarget;
|
||||
});
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
@@ -148,6 +161,12 @@ async function loadSchemas(connectionId: string, database: string, side: "source
|
||||
}
|
||||
}
|
||||
|
||||
function applyPendingTableSelection() {
|
||||
const pending = pendingSelectedTablesPrefill.value;
|
||||
selectedTables.value = pending ? new Set(sourceTables.value.filter((table) => pending.includes(table))) : new Set(sourceTables.value);
|
||||
pendingSelectedTablesPrefill.value = null;
|
||||
}
|
||||
|
||||
async function loadTables() {
|
||||
if (!sourceConnectionId.value || !sourceDatabase.value) {
|
||||
sourceTables.value = [];
|
||||
@@ -157,7 +176,7 @@ async function loadTables() {
|
||||
try {
|
||||
if (isMongoConnection(sourceConnectionId.value)) {
|
||||
sourceTables.value = (await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value)).map((c) => c.name);
|
||||
selectedTables.value = new Set(sourceTables.value);
|
||||
applyPendingTableSelection();
|
||||
return;
|
||||
}
|
||||
const config = store.getConfig(sourceConnectionId.value);
|
||||
@@ -165,7 +184,7 @@ async function loadTables() {
|
||||
const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value;
|
||||
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema);
|
||||
sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name);
|
||||
selectedTables.value = new Set(sourceTables.value);
|
||||
applyPendingTableSelection();
|
||||
} catch {
|
||||
sourceTables.value = [];
|
||||
} finally {
|
||||
@@ -174,6 +193,7 @@ async function loadTables() {
|
||||
}
|
||||
|
||||
const skipSourceWatch = ref(false);
|
||||
const skipTargetWatch = ref(false);
|
||||
|
||||
watch(sourceConnectionId, (id) => {
|
||||
if (skipSourceWatch.value) {
|
||||
@@ -183,6 +203,8 @@ watch(sourceConnectionId, (id) => {
|
||||
sourceDatabase.value = "";
|
||||
sourceTables.value = [];
|
||||
selectedTables.value.clear();
|
||||
pendingSourceSchemaPrefill.value = "";
|
||||
pendingSelectedTablesPrefill.value = null;
|
||||
loadDatabases(id, "source");
|
||||
});
|
||||
|
||||
@@ -195,7 +217,8 @@ watch(sourceDatabase, async (db) => {
|
||||
sourceSchemas.value = [];
|
||||
sourceSchema.value = db;
|
||||
} else if (isSchemaAware(config?.db_type)) {
|
||||
await loadSchemas(sourceConnectionId.value, db, "source");
|
||||
await loadSchemas(sourceConnectionId.value, db, "source", pendingSourceSchemaPrefill.value);
|
||||
pendingSourceSchemaPrefill.value = "";
|
||||
} else {
|
||||
sourceSchema.value = db;
|
||||
}
|
||||
@@ -205,6 +228,10 @@ watch(sourceDatabase, async (db) => {
|
||||
watch(sourceSchema, () => loadTables());
|
||||
|
||||
watch(targetConnectionId, (id) => {
|
||||
if (skipTargetWatch.value) {
|
||||
skipTargetWatch.value = false;
|
||||
return;
|
||||
}
|
||||
targetDatabase.value = "";
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = "";
|
||||
@@ -218,7 +245,8 @@ watch(targetDatabase, async (db) => {
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = db;
|
||||
} else if (isSchemaAware(config?.db_type)) {
|
||||
await loadSchemas(targetConnectionId.value, db, "target");
|
||||
await loadSchemas(targetConnectionId.value, db, "target", pendingTargetSchemaPrefill.value);
|
||||
pendingTargetSchemaPrefill.value = "";
|
||||
} else {
|
||||
targetSchema.value = db;
|
||||
}
|
||||
@@ -230,13 +258,20 @@ watch(
|
||||
async (val) => {
|
||||
if (val) {
|
||||
resetState();
|
||||
pendingSourceSchemaPrefill.value = props.prefillSchema ?? "";
|
||||
pendingSelectedTablesPrefill.value = props.prefillTables?.length ? [...props.prefillTables] : null;
|
||||
pendingTargetSchemaPrefill.value = props.prefillTargetSchema ?? "";
|
||||
if (props.prefillConnectionId) {
|
||||
skipSourceWatch.value = true;
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
if (props.prefillDatabase) {
|
||||
sourceDatabase.value = props.prefillDatabase;
|
||||
}
|
||||
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
|
||||
}
|
||||
if (props.prefillTargetConnectionId) {
|
||||
skipTargetWatch.value = true;
|
||||
targetConnectionId.value = props.prefillTargetConnectionId;
|
||||
await loadDatabases(props.prefillTargetConnectionId, "target");
|
||||
if (props.prefillTargetDatabase) targetDatabase.value = props.prefillTargetDatabase;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -251,12 +286,15 @@ function resetState() {
|
||||
sourceSchema.value = "";
|
||||
sourceTables.value = [];
|
||||
selectedTables.value.clear();
|
||||
pendingSourceSchemaPrefill.value = "";
|
||||
pendingSelectedTablesPrefill.value = null;
|
||||
tableSearch.value = "";
|
||||
targetConnectionId.value = "";
|
||||
targetDatabase.value = "";
|
||||
targetDatabases.value = [];
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = "";
|
||||
pendingTargetSchemaPrefill.value = "";
|
||||
createTable.value = true;
|
||||
transferMode.value = "append";
|
||||
targetTableNameCase.value = "preserve";
|
||||
|
||||
@@ -20,3 +20,23 @@ describe("DataTransferDialog layout", () => {
|
||||
expect(dialogSource).toContain('class="min-h-0 max-h-[200px] overflow-y-auto rounded-md border"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTransferDialog transfer prefill", () => {
|
||||
it("accepts source, target, schema, and selected-table prefills", () => {
|
||||
expect(dialogSource).toContain("prefillSchema?: string;");
|
||||
expect(dialogSource).toContain("prefillTables?: string[];");
|
||||
expect(dialogSource).toContain("prefillTargetConnectionId?: string;");
|
||||
expect(dialogSource).toContain("prefillTargetDatabase?: string;");
|
||||
expect(dialogSource).toContain("prefillTargetSchema?: string;");
|
||||
});
|
||||
|
||||
it("keeps only copied tables selected after loading the source list", () => {
|
||||
expect(dialogSource).toContain("function applyPendingTableSelection()");
|
||||
expect(dialogSource).toContain("new Set(sourceTables.value.filter((table) => pending.includes(table)))");
|
||||
});
|
||||
|
||||
it("allows a transfer between different schemas in the same database", () => {
|
||||
expect(dialogSource).toContain("const sameSourceAndTarget = sourceConnectionId.value === targetConnectionId.value");
|
||||
expect(dialogSource).toContain("effectiveSourceSchema === effectiveTargetSchema");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,11 @@ const pendingImportContent = ref("");
|
||||
|
||||
const transferPrefillConnectionId = ref("");
|
||||
const transferPrefillDatabase = ref("");
|
||||
const transferPrefillSchema = ref("");
|
||||
const transferPrefillTables = ref<string[]>([]);
|
||||
const transferPrefillTargetConnectionId = ref("");
|
||||
const transferPrefillTargetDatabase = ref("");
|
||||
const transferPrefillTargetSchema = ref("");
|
||||
const schemaDiffPrefillConnectionId = ref("");
|
||||
const schemaDiffPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillSchema = ref("");
|
||||
@@ -62,6 +67,16 @@ const databaseExportAllDatabases = ref(false);
|
||||
|
||||
let watchersRegistered = false;
|
||||
|
||||
function clearTransferPrefill() {
|
||||
transferPrefillConnectionId.value = "";
|
||||
transferPrefillDatabase.value = "";
|
||||
transferPrefillSchema.value = "";
|
||||
transferPrefillTables.value = [];
|
||||
transferPrefillTargetConnectionId.value = "";
|
||||
transferPrefillTargetDatabase.value = "";
|
||||
transferPrefillTargetSchema.value = "";
|
||||
}
|
||||
|
||||
export function useDialogSources() {
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -77,12 +92,21 @@ export function useDialogSources() {
|
||||
if (v) {
|
||||
transferPrefillConnectionId.value = v.connectionId;
|
||||
transferPrefillDatabase.value = v.database;
|
||||
transferPrefillSchema.value = v.schema ?? "";
|
||||
transferPrefillTables.value = v.tables ?? [];
|
||||
transferPrefillTargetConnectionId.value = v.targetConnectionId ?? "";
|
||||
transferPrefillTargetDatabase.value = v.targetDatabase ?? "";
|
||||
transferPrefillTargetSchema.value = v.targetSchema ?? "";
|
||||
showTransferDialog.value = true;
|
||||
connectionStore.transferSource = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(showTransferDialog, (open) => {
|
||||
if (!open) clearTransferPrefill();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => connectionStore.schemaDiffSource,
|
||||
(v) => {
|
||||
@@ -307,6 +331,11 @@ export function useDialogSources() {
|
||||
pendingImportContent,
|
||||
transferPrefillConnectionId,
|
||||
transferPrefillDatabase,
|
||||
transferPrefillSchema,
|
||||
transferPrefillTables,
|
||||
transferPrefillTargetConnectionId,
|
||||
transferPrefillTargetDatabase,
|
||||
transferPrefillTargetSchema,
|
||||
schemaDiffPrefillConnectionId,
|
||||
schemaDiffPrefillDatabase,
|
||||
schemaDiffPrefillSchema,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext } from "@/lib/table/tableClipboard";
|
||||
|
||||
describe("table clipboard contexts", () => {
|
||||
const source = { connectionId: "source", database: "app", schema: "public" };
|
||||
|
||||
it("returns the shared source context for tables copied together", () => {
|
||||
expect(tableClipboardSourceContext([source, { ...source }])).toEqual(source);
|
||||
});
|
||||
|
||||
it("rejects a clipboard that combines different source contexts", () => {
|
||||
expect(tableClipboardSourceContext([source, { ...source, schema: "audit" }])).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes a cross-schema target from the source", () => {
|
||||
expect(tableClipboardMatchesTarget([source], { ...source, schema: "archive" })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows paste for a transferable clipboard from another context", () => {
|
||||
const sourceTable = { ...source, tableName: "users" };
|
||||
const targetTable = { ...sourceTable, database: "archive" };
|
||||
|
||||
expect(tableClipboardMenuState([sourceTable], targetTable)).toBe("copy");
|
||||
expect(tableClipboardMenuState([sourceTable], targetTable, true)).toBe("copy-and-paste");
|
||||
});
|
||||
});
|
||||
@@ -32,13 +32,27 @@ export function tableClipboardMatchesTarget(entries: TableClipboardContext[], ta
|
||||
return !!target && entries.length > 0 && entries.every((entry) => tableClipboardEntryMatchesTarget(entry, target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the common source context for a copied table collection. Cross-database
|
||||
* transfer can only use one source connection/database/schema per request.
|
||||
*/
|
||||
export function tableClipboardSourceContext(entries: TableClipboardContext[]): TableClipboardContext | null {
|
||||
const source = entries[0];
|
||||
if (!source || !entries.every((entry) => tableClipboardEntryMatchesTarget(entry, source))) return null;
|
||||
return {
|
||||
connectionId: source.connectionId,
|
||||
database: source.database,
|
||||
schema: source.schema,
|
||||
};
|
||||
}
|
||||
|
||||
export function tableClipboardMatchesSingleSource(entries: TableClipboardTableContext[], source: TableClipboardTableContext): boolean {
|
||||
return entries.length === 1 && tableClipboardEntryMatchesTarget(entries[0]!, source) && entries[0]!.tableName === source.tableName;
|
||||
}
|
||||
|
||||
export function tableClipboardMenuState(entries: TableClipboardTableContext[], source: TableClipboardTableContext): TableClipboardMenuState {
|
||||
if (!tableClipboardMatchesTarget(entries, source)) return "copy";
|
||||
return tableClipboardMatchesSingleSource(entries, source) ? "paste" : "copy-and-paste";
|
||||
export function tableClipboardMenuState(entries: TableClipboardTableContext[], target: TableClipboardTableContext, canPasteAcrossContext = false): TableClipboardMenuState {
|
||||
if (!tableClipboardMatchesTarget(entries, target)) return canPasteAcrossContext && entries.length > 0 ? "copy-and-paste" : "copy";
|
||||
return tableClipboardMatchesSingleSource(entries, target) ? "paste" : "copy-and-paste";
|
||||
}
|
||||
|
||||
export function supportsWholeRowTableDataCopy(databaseType: DatabaseType | undefined): boolean {
|
||||
|
||||
@@ -355,7 +355,15 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const completionMetadataLimiter = new MetadataTaskLimiter(COMPLETION_METADATA_CONCURRENCY, (event) => {
|
||||
console.debug("[DBX][completion-metadata:limit]", event);
|
||||
});
|
||||
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
const transferSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tables?: string[];
|
||||
targetConnectionId?: string;
|
||||
targetDatabase?: string;
|
||||
targetSchema?: string;
|
||||
} | null>(null);
|
||||
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
|
||||
const dataCompareSource = ref<{
|
||||
connectionId: string;
|
||||
|
||||
Reference in new issue
Block a user