feat(database): support Doris and StarRocks catalogs
This commit is contained in:
44 files changed
+1330
-242
No files matched your search
@@ -178,6 +178,7 @@ watch(
|
||||
v-model:open="dialogs.showTransferDialog.value"
|
||||
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.transferPrefillDatabase.value"
|
||||
:prefill-catalog="dialogs.transferPrefillCatalog.value"
|
||||
:prefill-schema="dialogs.transferPrefillSchema.value"
|
||||
:prefill-tables="dialogs.transferPrefillTables.value"
|
||||
:prefill-target-connection-id="dialogs.transferPrefillTargetConnectionId.value"
|
||||
|
||||
@@ -15,8 +15,11 @@ import * as api from "@/lib/backend/api";
|
||||
import type { TransferMode, TransferTableNameCase } from "@/lib/backend/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
|
||||
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions";
|
||||
import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isSameTransferDatabase, normalizeTransferCatalog } from "@/lib/database/dataTransferSelection";
|
||||
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";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -26,6 +29,7 @@ const open = defineModel<boolean>("open", { default: false });
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillCatalog?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTables?: string[];
|
||||
prefillTargetConnectionId?: string;
|
||||
@@ -39,6 +43,8 @@ const sqlConnections = computed(() => store.connections.filter((c) => supportsTr
|
||||
|
||||
// Source state
|
||||
const sourceConnectionId = ref("");
|
||||
const sourceCatalog = ref("");
|
||||
const sourceCatalogs = ref<CatalogInfo[]>([]);
|
||||
const sourceDatabase = ref("");
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
const sourceSchemas = ref<string[]>([]);
|
||||
@@ -52,6 +58,8 @@ const pendingSelectedTablesPrefill = ref<string[] | null>(null);
|
||||
|
||||
// Target state
|
||||
const targetConnectionId = ref("");
|
||||
const targetCatalog = ref("");
|
||||
const targetCatalogs = ref<CatalogInfo[]>([]);
|
||||
const targetDatabase = ref("");
|
||||
const targetDatabases = ref<string[]>([]);
|
||||
const targetSchemas = ref<string[]>([]);
|
||||
@@ -68,7 +76,7 @@ const ownershipDialogOpen = ref(false);
|
||||
const ownershipMissingOwners = ref<string[]>([]);
|
||||
const ownershipTargetOwner = ref("");
|
||||
const pendingOwnershipRequest = ref<api.TransferRequest | null>(null);
|
||||
const pendingOwnershipRefresh = ref<{ targetConnection: string; targetDatabase: string; targetSchema: string; shouldRefreshTargetTree: boolean } | null>(null);
|
||||
const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null);
|
||||
|
||||
const filteredTables = computed(() => {
|
||||
const q = tableSearch.value.toLowerCase();
|
||||
@@ -85,11 +93,22 @@ function isMongoConnection(id: string): boolean {
|
||||
return connectionType(id) === "mongodb";
|
||||
}
|
||||
|
||||
function isCatalogCapable(id: string): boolean {
|
||||
const config = store.getConfig(id);
|
||||
return isDorisFamilyCatalogCapable(config?.db_type, config?.driver_profile);
|
||||
}
|
||||
|
||||
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;
|
||||
const sameCatalogAndDatabase = isSameTransferDatabase(
|
||||
{ connectionId: sourceConnectionId.value, catalog: sourceCatalog.value, catalogs: sourceCatalogs.value, database: sourceDatabase.value },
|
||||
{ connectionId: targetConnectionId.value, catalog: targetCatalog.value, catalogs: targetCatalogs.value, database: targetDatabase.value },
|
||||
);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
function toggleSelectAll() {
|
||||
@@ -108,6 +127,37 @@ function toggleTable(table: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalogs(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId || !isCatalogCapable(connectionId)) {
|
||||
if (side === "source") {
|
||||
sourceCatalogs.value = [];
|
||||
sourceCatalog.value = "";
|
||||
} else {
|
||||
targetCatalogs.value = [];
|
||||
targetCatalog.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const catalogs = await api.listDorisCatalogs(connectionId);
|
||||
if (side === "source") {
|
||||
sourceCatalogs.value = catalogs;
|
||||
sourceCatalog.value = catalogs.length === 1 ? catalogs[0].name : "";
|
||||
} else {
|
||||
targetCatalogs.value = catalogs;
|
||||
targetCatalog.value = catalogs.length === 1 ? catalogs[0].name : "";
|
||||
}
|
||||
} catch {
|
||||
if (side === "source") {
|
||||
sourceCatalogs.value = [];
|
||||
sourceCatalog.value = "";
|
||||
} else {
|
||||
targetCatalogs.value = [];
|
||||
targetCatalog.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string, target: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
try {
|
||||
@@ -128,6 +178,26 @@ async function loadDatabases(connectionId: string, target: "source" | "target")
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabasesForCatalog(connectionId: string, catalog: string, target: "source" | "target") {
|
||||
if (!connectionId || !catalog) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
if (!config) return;
|
||||
const names = await fetchCatalogNamespaceOptions(connectionId, catalog, config);
|
||||
if (target === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
} else {
|
||||
targetDatabases.value = names;
|
||||
targetDatabase.value = names.length === 1 ? names[0] : "";
|
||||
}
|
||||
} catch {
|
||||
if (target === "source") sourceDatabases.value = [];
|
||||
else targetDatabases.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchemas(connectionId: string, database: string, side: "source" | "target", preferredSchema = "") {
|
||||
if (!connectionId || !database) return;
|
||||
if (isMongoConnection(connectionId)) {
|
||||
@@ -182,7 +252,8 @@ async function loadTables() {
|
||||
const config = store.getConfig(sourceConnectionId.value);
|
||||
const needsSchema = isSchemaAware(config?.db_type);
|
||||
const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value;
|
||||
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema);
|
||||
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);
|
||||
applyPendingTableSelection();
|
||||
} catch {
|
||||
@@ -195,17 +266,36 @@ async function loadTables() {
|
||||
const skipSourceWatch = ref(false);
|
||||
const skipTargetWatch = ref(false);
|
||||
|
||||
watch(sourceConnectionId, (id) => {
|
||||
watch(sourceConnectionId, async (id) => {
|
||||
if (skipSourceWatch.value) {
|
||||
skipSourceWatch.value = false;
|
||||
return;
|
||||
}
|
||||
sourceCatalog.value = "";
|
||||
sourceCatalogs.value = [];
|
||||
sourceDatabase.value = "";
|
||||
sourceTables.value = [];
|
||||
selectedTables.value.clear();
|
||||
pendingSourceSchemaPrefill.value = "";
|
||||
pendingSelectedTablesPrefill.value = null;
|
||||
loadDatabases(id, "source");
|
||||
if (isCatalogCapable(id)) {
|
||||
await loadCatalogs(id, "source");
|
||||
if (sourceCatalog.value) {
|
||||
await loadDatabasesForCatalog(id, sourceCatalog.value, "source");
|
||||
}
|
||||
} else {
|
||||
await loadDatabases(id, "source");
|
||||
}
|
||||
});
|
||||
|
||||
watch(sourceCatalog, async (catalog) => {
|
||||
if (!sourceConnectionId.value) return;
|
||||
sourceDatabase.value = "";
|
||||
sourceTables.value = [];
|
||||
selectedTables.value.clear();
|
||||
if (catalog) {
|
||||
await loadDatabasesForCatalog(sourceConnectionId.value, catalog, "source");
|
||||
}
|
||||
});
|
||||
|
||||
watch(sourceDatabase, async (db) => {
|
||||
@@ -227,15 +317,35 @@ watch(sourceDatabase, async (db) => {
|
||||
|
||||
watch(sourceSchema, () => loadTables());
|
||||
|
||||
watch(targetConnectionId, (id) => {
|
||||
watch(targetConnectionId, async (id) => {
|
||||
if (skipTargetWatch.value) {
|
||||
skipTargetWatch.value = false;
|
||||
return;
|
||||
}
|
||||
targetCatalog.value = "";
|
||||
targetCatalogs.value = [];
|
||||
targetDatabase.value = "";
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = "";
|
||||
loadDatabases(id, "target");
|
||||
pendingTargetSchemaPrefill.value = "";
|
||||
if (isCatalogCapable(id)) {
|
||||
await loadCatalogs(id, "target");
|
||||
if (targetCatalog.value) {
|
||||
await loadDatabasesForCatalog(id, targetCatalog.value, "target");
|
||||
}
|
||||
} else {
|
||||
await loadDatabases(id, "target");
|
||||
}
|
||||
});
|
||||
|
||||
watch(targetCatalog, async (catalog) => {
|
||||
if (!targetConnectionId.value) return;
|
||||
targetDatabase.value = "";
|
||||
targetSchemas.value = [];
|
||||
targetSchema.value = "";
|
||||
if (catalog) {
|
||||
await loadDatabasesForCatalog(targetConnectionId.value, catalog, "target");
|
||||
}
|
||||
});
|
||||
|
||||
watch(targetDatabase, async (db) => {
|
||||
@@ -264,13 +374,30 @@ watch(
|
||||
if (props.prefillConnectionId) {
|
||||
skipSourceWatch.value = true;
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
if (isCatalogCapable(props.prefillConnectionId)) {
|
||||
await loadCatalogs(props.prefillConnectionId, "source");
|
||||
if (props.prefillCatalog) {
|
||||
sourceCatalog.value = props.prefillCatalog;
|
||||
}
|
||||
if (sourceCatalog.value) {
|
||||
await loadDatabasesForCatalog(props.prefillConnectionId, sourceCatalog.value, "source");
|
||||
}
|
||||
} else {
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
}
|
||||
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
|
||||
}
|
||||
if (props.prefillTargetConnectionId) {
|
||||
skipTargetWatch.value = true;
|
||||
targetConnectionId.value = props.prefillTargetConnectionId;
|
||||
await loadDatabases(props.prefillTargetConnectionId, "target");
|
||||
if (isCatalogCapable(props.prefillTargetConnectionId)) {
|
||||
await loadCatalogs(props.prefillTargetConnectionId, "target");
|
||||
if (targetCatalog.value) {
|
||||
await loadDatabasesForCatalog(props.prefillTargetConnectionId, targetCatalog.value, "target");
|
||||
}
|
||||
} else {
|
||||
await loadDatabases(props.prefillTargetConnectionId, "target");
|
||||
}
|
||||
if (props.prefillTargetDatabase) targetDatabase.value = props.prefillTargetDatabase;
|
||||
}
|
||||
}
|
||||
@@ -280,6 +407,8 @@ watch(
|
||||
|
||||
function resetState() {
|
||||
sourceConnectionId.value = "";
|
||||
sourceCatalog.value = "";
|
||||
sourceCatalogs.value = [];
|
||||
sourceDatabase.value = "";
|
||||
sourceDatabases.value = [];
|
||||
sourceSchemas.value = [];
|
||||
@@ -290,6 +419,8 @@ function resetState() {
|
||||
pendingSelectedTablesPrefill.value = null;
|
||||
tableSearch.value = "";
|
||||
targetConnectionId.value = "";
|
||||
targetCatalog.value = "";
|
||||
targetCatalogs.value = [];
|
||||
targetDatabase.value = "";
|
||||
targetDatabases.value = [];
|
||||
targetSchemas.value = [];
|
||||
@@ -323,9 +454,11 @@ async function startTransfer() {
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabaseName,
|
||||
sourceSchema: effectiveSourceSchema,
|
||||
sourceCatalog: normalizeTransferCatalog(sourceCatalog.value, sourceCatalogs.value) || undefined,
|
||||
targetConnectionId: targetConnection,
|
||||
targetDatabase: targetDatabaseName,
|
||||
targetSchema: effectiveTargetSchema,
|
||||
targetCatalog: normalizeTransferCatalog(targetCatalog.value, targetCatalogs.value) || undefined,
|
||||
tables: [...selectedTables.value],
|
||||
createTable: createTable.value,
|
||||
mode: transferMode.value,
|
||||
@@ -342,9 +475,6 @@ async function startTransfer() {
|
||||
ownershipTargetOwner.value = preview.targetOwner;
|
||||
pendingOwnershipRequest.value = request;
|
||||
pendingOwnershipRefresh.value = {
|
||||
targetConnection,
|
||||
targetDatabase: targetDatabaseName,
|
||||
targetSchema: effectiveTargetSchema,
|
||||
shouldRefreshTargetTree,
|
||||
};
|
||||
ownershipDialogOpen.value = true;
|
||||
@@ -357,16 +487,16 @@ async function startTransfer() {
|
||||
}
|
||||
}
|
||||
|
||||
runTransfer(request, targetConnection, targetDatabaseName, effectiveTargetSchema, shouldRefreshTargetTree);
|
||||
runTransfer(request, shouldRefreshTargetTree);
|
||||
}
|
||||
|
||||
function runTransfer(request: api.TransferRequest, targetConnection: string, targetDatabaseName: string, effectiveTargetSchema: string, shouldRefreshTargetTree: boolean) {
|
||||
function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: boolean) {
|
||||
isSubmitting.value = true;
|
||||
startDataTransferTask(request, `${request.sourceDatabase} → ${targetDatabaseName}`, {
|
||||
startDataTransferTask(request, `${request.sourceDatabase} → ${request.targetDatabase}`, {
|
||||
formatOverlapError: (tables) => t("transfer.targetTableBusy", { tables: tables.join(", ") }),
|
||||
onDone: async () => {
|
||||
if (shouldRefreshTargetTree) {
|
||||
await store.refreshObjectListTreeNode(targetConnection, targetDatabaseName, effectiveTargetSchema);
|
||||
await store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -386,7 +516,7 @@ function resolveOwnershipDecision(policy: api.TransferOwnershipPolicy | null) {
|
||||
isSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
runTransfer({ ...request, ownershipPolicy: policy }, refresh.targetConnection, refresh.targetDatabase, refresh.targetSchema, refresh.shouldRefreshTargetTree);
|
||||
runTransfer({ ...request, ownershipPolicy: policy }, refresh.shouldRefreshTargetTree);
|
||||
}
|
||||
|
||||
function getConnectionName(id: string) {
|
||||
@@ -437,6 +567,21 @@ function getConnectionName(id: string) {
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
|
||||
<!-- Source Catalog (Doris/StarRocks multi-catalog) -->
|
||||
<div v-if="sourceCatalogs.length > 1" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("transfer.sourceCatalog") }}</Label>
|
||||
<SearchableSelect
|
||||
v-model="sourceCatalog"
|
||||
:options="sourceCatalogs.map((c) => c.name)"
|
||||
:placeholder="t('transfer.selectCatalog')"
|
||||
:search-placeholder="t('transfer.searchCatalog')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("transfer.sourceDatabase") }}</Label>
|
||||
<SearchableSelect
|
||||
@@ -501,6 +646,21 @@ function getConnectionName(id: string) {
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
|
||||
<!-- Target Catalog (Doris/StarRocks multi-catalog) -->
|
||||
<div v-if="targetCatalogs.length > 1" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("transfer.targetCatalog") }}</Label>
|
||||
<SearchableSelect
|
||||
v-model="targetCatalog"
|
||||
:options="targetCatalogs.map((c) => c.name)"
|
||||
:placeholder="t('transfer.selectCatalog')"
|
||||
:search-placeholder="t('transfer.searchCatalog')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("transfer.targetDatabase") }}</Label>
|
||||
<SearchableSelect
|
||||
|
||||
@@ -36,7 +36,12 @@ describe("DataTransferDialog transfer prefill", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
expect(dialogSource).toContain("isSameTransferDatabase");
|
||||
expect(dialogSource).toContain("const sameSourceAndTarget = sameCatalogAndDatabase && effectiveSourceSchema === effectiveTargetSchema");
|
||||
});
|
||||
|
||||
it("keeps catalog filtering and completion refresh catalog-aware", () => {
|
||||
expect(dialogSource).toContain("fetchCatalogNamespaceOptions(connectionId, catalog, config)");
|
||||
expect(dialogSource).toContain("store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user