diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index e01a2bae4c..131d304e51 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -4,6 +4,7 @@ import { uuid } from "@/lib/common/utils"; import { useI18n } from "vue-i18n"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { buildTransferObjectSelections } from "./transferSelections"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -13,7 +14,9 @@ import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import { connectionIconType } from "@/lib/connection/connectionPresentation"; import * as api from "@/lib/backend/api"; -import type { TransferMode, TransferTableNameCase } from "@/lib/backend/api"; +import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api"; +import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds"; +import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue"; import type { DatabaseType } from "@/types/database"; import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities"; import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport"; @@ -21,7 +24,7 @@ import { isSameTransferDatabase, normalizeTransferCatalog } from "@/lib/database import { databaseOptionsForConnection, fetchCatalogNamespaceOptions, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions"; import { useExportTracker } from "@/composables/useExportTracker"; import type { CatalogInfo } from "@/types/database"; -import { ArrowRightLeft, ArrowLeftRight, Loader2, Square, CheckSquare } from "@lucide/vue"; +import { ArrowRightLeft, ArrowLeftRight, Loader2 } from "@lucide/vue"; const { t } = useI18n(); const { startDataTransferTask } = useExportTracker(); @@ -50,10 +53,73 @@ const sourceDatabase = ref(""); const sourceDatabases = ref([]); const sourceSchemas = ref([]); const sourceSchema = ref(""); -const sourceTables = ref([]); -const selectedTables = ref>(new Set()); -const tableSearch = ref(""); -const loadingTables = ref(false); +const objectGroups = ref>>({}); +const selectedObjects = ref>>>({}); +const objectSearch = ref(""); +const loadingObjects = ref(false); +const transferContent = ref("structureAndData"); + +const selectedTables = computed(() => new Set(selectedObjects.value.TABLE ?? [])); + +const OBJECT_KIND_LABEL_KEY: Record = { + TABLE: "objectTypeTable", + VIEW: "objectTypeView", + MATERIALIZED_VIEW: "objectTypeMaterializedView", + PROCEDURE: "objectTypeProcedure", + FUNCTION: "objectTypeFunction", + TRIGGER: "objectTypeTrigger", + SEQUENCE: "objectTypeSequence", + EVENT: "objectTypeEvent", +}; + +const treeSelection = computed>({ + get: () => Object.fromEntries(Object.entries(selectedObjects.value).map(([k, v]) => [k, [...(v ?? [])]])), + set: (value) => { + const next: Partial>> = {}; + for (const [k, names] of Object.entries(value)) { + if (names.length > 0) next[k as TransferObjectKind] = new Set(names); + } + selectedObjects.value = next; + }, +}); + +const treeGroups = computed(() => + (Object.keys(objectGroups.value) as TransferObjectKind[]).map((kind) => ({ + kind, + label: t(`transfer.${OBJECT_KIND_LABEL_KEY[kind]}`), + items: objectGroups.value[kind] ?? [], + })), +); + +const treeDisabledGroups = computed(() => { + const presentKinds = Object.keys(objectGroups.value) as TransferObjectKind[]; + if (transferContent.value === "dataOnly") { + return presentKinds.filter((k) => k !== "TABLE"); + } + const sourceConfig = store.getConfig(sourceConnectionId.value); + const targetConfig = store.getConfig(targetConnectionId.value); + const allowed = crossFamilyTransferableKinds(sourceConfig?.db_type, targetConfig?.db_type); + return presentKinds.filter((k) => k !== "TABLE" && !allowed.includes(k)); +}); + +const treeDisabledHints = computed>(() => { + const hints: Record = {}; + const dataOnly = transferContent.value === "dataOnly"; + for (const kind of treeDisabledGroups.value) { + hints[kind] = dataOnly ? t("transfer.objectDataOnlyDisabled") : t("transfer.objectCrossFamilyDisabled"); + } + return hints; +}); + +const showCrossFamilyViewHint = computed(() => { + const sourceConfig = store.getConfig(sourceConnectionId.value); + const targetConfig = store.getConfig(targetConnectionId.value); + if (transferContent.value === "dataOnly") return false; + if (!sourceConfig || !targetConfig) return false; + const allowed = crossFamilyTransferableKinds(sourceConfig.db_type, targetConfig.db_type); + if (!allowed.includes("VIEW")) return false; + return !isSameTransferFamily(sourceConfig.db_type, targetConfig.db_type) && (selectedObjects.value.VIEW?.size ?? 0) > 0; +}); const pendingSourceSchemaPrefill = ref(""); const pendingSelectedTablesPrefill = ref(null); @@ -68,7 +134,6 @@ const targetSchema = ref(""); const pendingTargetSchemaPrefill = ref(""); // Options -const createTable = ref(true); const transferMode = ref("append"); const targetTableNameCase = ref("preserve"); const batchSize = ref(1000); @@ -79,13 +144,6 @@ const ownershipTargetOwner = ref(""); const pendingOwnershipRequest = ref(null); const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null); -const filteredTables = computed(() => { - const q = tableSearch.value.toLowerCase(); - return q ? sourceTables.value.filter((t) => t.toLowerCase().includes(q)) : sourceTables.value; -}); - -const allSelected = computed(() => filteredTables.value.length > 0 && filteredTables.value.every((t) => selectedTables.value.has(t))); - function connectionType(id: string): DatabaseType | undefined { return store.connections.find((c) => c.id === id)?.db_type; } @@ -108,26 +166,17 @@ const canStart = computed(() => { ); const sameSourceAndTarget = sameCatalogAndDatabase && effectiveSourceSchema === effectiveTargetSchema; return ( - !!sourceConnectionId.value && !!sourceDatabase.value && !!targetConnectionId.value && !!targetDatabase.value && (sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) && (targetCatalogs.value.length <= 1 || !!targetCatalog.value) && selectedTables.value.size > 0 && !sameSourceAndTarget + !!sourceConnectionId.value && + !!sourceDatabase.value && + !!targetConnectionId.value && + !!targetDatabase.value && + (sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) && + (targetCatalogs.value.length <= 1 || !!targetCatalog.value) && + (selectedTables.value.size > 0 || Object.values(selectedObjects.value).some((names) => names.size > 0)) && + !sameSourceAndTarget ); }); -function toggleSelectAll() { - if (allSelected.value) { - filteredTables.value.forEach((t) => selectedTables.value.delete(t)); - } else { - filteredTables.value.forEach((t) => selectedTables.value.add(t)); - } -} - -function toggleTable(table: string) { - if (selectedTables.value.has(table)) { - selectedTables.value.delete(table); - } else { - selectedTables.value.add(table); - } -} - async function loadCatalogs(connectionId: string, side: "source" | "target") { if (!connectionId || !isCatalogCapable(connectionId)) { if (side === "source") { @@ -234,19 +283,26 @@ 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); + const tables = objectGroups.value.TABLE ?? []; + if (pending) { + const chosen = new Set(tables.filter((table) => pending.includes(table))); + if (chosen.size > 0) { + selectedObjects.value = { ...selectedObjects.value, TABLE: chosen }; + } + } pendingSelectedTablesPrefill.value = null; } -async function loadTables() { +async function loadObjects() { if (!sourceConnectionId.value || !sourceDatabase.value) { - sourceTables.value = []; + objectGroups.value = {}; return; } - loadingTables.value = true; + loadingObjects.value = true; try { if (isMongoConnection(sourceConnectionId.value)) { - sourceTables.value = (await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value)).map((c) => c.name); + const collections = await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value); + objectGroups.value = { TABLE: collections.map((c) => c.name) }; applyPendingTableSelection(); return; } @@ -254,13 +310,27 @@ async function loadTables() { const needsSchema = isSchemaAware(config?.db_type); const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value; const catalog = sourceCatalog.value || undefined; - const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema, undefined, undefined, undefined, undefined, catalog); - sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name); + const kinds = transferObjectKindsForDatabase(config?.db_type); + const groups: Partial> = {}; + for (const kind of kinds) { + try { + if (kind === "TABLE") { + const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema, undefined, undefined, undefined, undefined, catalog); + groups.TABLE = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name); + } else { + const objects = await api.listObjects(sourceConnectionId.value, sourceDatabase.value, schema, [kind], undefined, undefined, undefined, catalog); + groups[kind] = objects.map((o) => o.name); + } + } catch { + groups[kind] = []; + } + } + objectGroups.value = groups; applyPendingTableSelection(); } catch { - sourceTables.value = []; + objectGroups.value = {}; } finally { - loadingTables.value = false; + loadingObjects.value = false; } } @@ -275,8 +345,8 @@ watch(sourceConnectionId, async (id) => { sourceCatalog.value = ""; sourceCatalogs.value = []; sourceDatabase.value = ""; - sourceTables.value = []; - selectedTables.value.clear(); + objectGroups.value = {}; + selectedObjects.value = {}; pendingSourceSchemaPrefill.value = ""; pendingSelectedTablesPrefill.value = null; if (isCatalogCapable(id)) { @@ -292,8 +362,8 @@ watch(sourceConnectionId, async (id) => { watch(sourceCatalog, async (catalog) => { if (!sourceConnectionId.value) return; sourceDatabase.value = ""; - sourceTables.value = []; - selectedTables.value.clear(); + objectGroups.value = {}; + selectedObjects.value = {}; if (catalog) { await loadDatabasesForCatalog(sourceConnectionId.value, catalog, "source"); } @@ -316,7 +386,7 @@ watch(sourceDatabase, async (db) => { } }); -watch(sourceSchema, () => loadTables()); +watch(sourceSchema, () => loadObjects()); watch(targetConnectionId, async (id) => { if (skipTargetWatch.value) { @@ -414,11 +484,11 @@ function resetState() { sourceDatabases.value = []; sourceSchemas.value = []; sourceSchema.value = ""; - sourceTables.value = []; - selectedTables.value.clear(); + objectGroups.value = {}; + selectedObjects.value = {}; pendingSourceSchemaPrefill.value = ""; pendingSelectedTablesPrefill.value = null; - tableSearch.value = ""; + objectSearch.value = ""; targetConnectionId.value = ""; targetCatalog.value = ""; targetCatalogs.value = []; @@ -427,7 +497,7 @@ function resetState() { targetSchemas.value = []; targetSchema.value = ""; pendingTargetSchemaPrefill.value = ""; - createTable.value = true; + transferContent.value = "structureAndData"; transferMode.value = "append"; targetTableNameCase.value = "preserve"; batchSize.value = 1000; @@ -448,7 +518,7 @@ async function startTransfer() { const sourceDatabaseName = sourceDatabase.value; const targetConnection = targetConnectionId.value; const targetDatabaseName = targetDatabase.value; - const shouldRefreshTargetTree = createTable.value; + const shouldRefreshTargetTree = transferContent.value !== "dataOnly"; const request: api.TransferRequest = { transferId: uuid(), @@ -461,14 +531,16 @@ async function startTransfer() { targetSchema: effectiveTargetSchema, targetCatalog: normalizeTransferCatalog(targetCatalog.value, targetCatalogs.value) || undefined, tables: [...selectedTables.value], - createTable: createTable.value, + createTable: transferContent.value !== "dataOnly", + content: transferContent.value, + objects: buildTransferObjectSelections(selectedObjects.value, treeDisabledGroups.value), mode: transferMode.value, targetTableNameCase: targetTableNameCase.value, ownershipPolicy: "preserve", batchSize: batchSize.value, }; - if (createTable.value) { + if (transferContent.value !== "dataOnly") { try { const preview = await api.previewTransferOwnership(request); if (preview.missingOwners.length > 0) { @@ -535,8 +607,8 @@ function getConnectionName(id: string) { -
-
+
+
@@ -693,48 +765,47 @@ function getConnectionName(id: string) {
- +
- {{ t("transfer.tables") }} - ({{ selectedTables.size }}/{{ sourceTables.length }}) + {{ t("transfer.objects") }} + ({{ selectedTables.size }}/{{ objectGroups.TABLE.length }})
-
- - -
- - {{ t("common.loading") }} -
-
+
{{ t("transfer.selectSourceFirst") }}
-
- {{ t("transfer.noTables") }} -
-
-
- - - {{ table }} -
+ +
+ {{ t("transfer.crossFamilyViewHint") }}
-
- - - {{ t("transfer.createTable") }} +
+ +
+ + + +
-
- +
+