mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-28 08:08:02 +00:00
feat: add schema comparison functionality and localization support
This commit is contained in:
+18
@@ -34,6 +34,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import QueryHistory from "@/components/editor/QueryHistory.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import DataTransferDialog from "@/components/transfer/DataTransferDialog.vue";
|
||||
import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -102,8 +103,11 @@ const selectedSql = ref("");
|
||||
const formatSqlRequestId = ref(0);
|
||||
const showDangerDialog = ref(false);
|
||||
const showTransferDialog = ref(false);
|
||||
const showSchemaDiffDialog = ref(false);
|
||||
const transferPrefillConnectionId = ref("");
|
||||
const transferPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillConnectionId = ref("");
|
||||
const schemaDiffPrefillDatabase = ref("");
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
const checkingUpdates = ref(false);
|
||||
@@ -135,6 +139,15 @@ watch(() => connectionStore.transferSource, (v) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => connectionStore.schemaDiffSource, (v) => {
|
||||
if (v) {
|
||||
schemaDiffPrefillConnectionId.value = v.connectionId;
|
||||
schemaDiffPrefillDatabase.value = v.database;
|
||||
showSchemaDiffDialog.value = true;
|
||||
connectionStore.schemaDiffSource = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onConnectionConnectStarted(name: string) {
|
||||
toast(t("connection.connecting", { name }), 30000);
|
||||
}
|
||||
@@ -1140,6 +1153,11 @@ async function setupFileDrop() {
|
||||
:prefill-connection-id="transferPrefillConnectionId"
|
||||
:prefill-database="transferPrefillDatabase"
|
||||
/>
|
||||
<SchemaDiffDialog
|
||||
v-model:open="showSchemaDiffDialog"
|
||||
:prefill-connection-id="schemaDiffPrefillConnectionId"
|
||||
:prefill-database="schemaDiffPrefillDatabase"
|
||||
/>
|
||||
<Dialog v-model:open="showUpdateDialog">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogHeader, DialogTitle,
|
||||
DialogFooter, DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import {
|
||||
diffColumns, diffIndexes, diffTables, generateSyncSql,
|
||||
type TableDiff,
|
||||
} from "@/lib/schemaDiff";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { Loader2, Copy, Play, GitCompareArrows } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const store = useConnectionStore();
|
||||
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
}>();
|
||||
|
||||
const sourceConnectionId = ref("");
|
||||
const sourceDatabase = ref("");
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
const sourceSchema = ref("");
|
||||
|
||||
const targetConnectionId = ref("");
|
||||
const targetDatabase = ref("");
|
||||
const targetDatabases = ref<string[]>([]);
|
||||
const targetSchema = ref("");
|
||||
|
||||
const step = ref<"select" | "comparing" | "result">("select");
|
||||
const diffs = ref<TableDiff[]>([]);
|
||||
const syncSql = ref("");
|
||||
const executing = ref(false);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) =>
|
||||
!["redis", "mongodb", "elasticsearch"].includes(c.db_type),
|
||||
),
|
||||
);
|
||||
|
||||
const canCompare = computed(() =>
|
||||
sourceConnectionId.value && sourceDatabase.value &&
|
||||
targetConnectionId.value && targetDatabase.value,
|
||||
);
|
||||
|
||||
function connectionIconType(connectionId: string) {
|
||||
const config = store.getConfig(connectionId);
|
||||
return config?.driver_profile || config?.db_type || "mysql";
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const dbs = await api.listDatabases(connectionId);
|
||||
const names = dbs.map((d) => d.name);
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
} else {
|
||||
targetDatabases.value = names;
|
||||
targetDatabase.value = names.length === 1 ? names[0] : "";
|
||||
}
|
||||
} catch {
|
||||
if (side === "source") sourceDatabases.value = [];
|
||||
else targetDatabases.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSchema(connectionId: string, database: string): Promise<string> {
|
||||
const config = store.getConfig(connectionId);
|
||||
const needsSchema = config?.db_type === "postgres" || config?.db_type === "sqlserver" || config?.db_type === "oracle" || config?.db_type === "redshift";
|
||||
if (needsSchema) {
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
return schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
async function startCompare() {
|
||||
if (!canCompare.value) return;
|
||||
step.value = "comparing";
|
||||
diffs.value = [];
|
||||
syncSql.value = "";
|
||||
|
||||
try {
|
||||
await store.ensureConnected(sourceConnectionId.value);
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
|
||||
const srcSchema = await resolveSchema(sourceConnectionId.value, sourceDatabase.value);
|
||||
const tgtSchema = await resolveSchema(targetConnectionId.value, targetDatabase.value);
|
||||
sourceSchema.value = srcSchema;
|
||||
targetSchema.value = tgtSchema;
|
||||
|
||||
const [srcTables, tgtTables] = await Promise.all([
|
||||
api.listTables(sourceConnectionId.value, sourceDatabase.value, srcSchema),
|
||||
api.listTables(targetConnectionId.value, targetDatabase.value, tgtSchema),
|
||||
]);
|
||||
|
||||
const srcNames = srcTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name);
|
||||
const tgtNames = tgtTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name);
|
||||
const { added, removed, common } = diffTables(srcNames, tgtNames);
|
||||
|
||||
const result: TableDiff[] = [];
|
||||
|
||||
for (const name of added) {
|
||||
const ddl = await api.getTableDdl(sourceConnectionId.value, sourceDatabase.value, srcSchema, name);
|
||||
result.push({ type: "added", name, ddl });
|
||||
}
|
||||
|
||||
for (const name of removed) {
|
||||
result.push({ type: "removed", name });
|
||||
}
|
||||
|
||||
for (const name of common) {
|
||||
const [srcCols, tgtCols, srcIdx, tgtIdx] = await Promise.all([
|
||||
api.getColumns(sourceConnectionId.value, sourceDatabase.value, srcSchema, name),
|
||||
api.getColumns(targetConnectionId.value, targetDatabase.value, tgtSchema, name),
|
||||
api.listIndexes(sourceConnectionId.value, sourceDatabase.value, srcSchema, name),
|
||||
api.listIndexes(targetConnectionId.value, targetDatabase.value, tgtSchema, name),
|
||||
]);
|
||||
|
||||
const colDiffs = diffColumns(srcCols, tgtCols);
|
||||
const idxDiffs = diffIndexes(srcIdx, tgtIdx);
|
||||
|
||||
if (colDiffs.length > 0 || idxDiffs.length > 0) {
|
||||
result.push({
|
||||
type: "modified",
|
||||
name,
|
||||
columns: colDiffs.length > 0 ? colDiffs : undefined,
|
||||
indexes: idxDiffs.length > 0 ? idxDiffs : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
diffs.value = result;
|
||||
const srcConfig = store.getConfig(targetConnectionId.value);
|
||||
syncSql.value = generateSyncSql(result, srcConfig?.db_type || "mysql");
|
||||
step.value = "result";
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
step.value = "select";
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSql() {
|
||||
if (!syncSql.value.trim() || executing.value) return;
|
||||
executing.value = true;
|
||||
try {
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
const statements = syncSql.value
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !s.startsWith("--"));
|
||||
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(targetConnectionId.value, targetDatabase.value, sql);
|
||||
}
|
||||
toast(t("diff.syncSuccess"), 2000);
|
||||
open.value = false;
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
executing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copySql() {
|
||||
navigator.clipboard.writeText(syncSql.value);
|
||||
toast(t("grid.copied"));
|
||||
}
|
||||
|
||||
function diffBadgeVariant(type: string) {
|
||||
if (type === "added") return "default";
|
||||
if (type === "removed") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function diffLabel(type: string) {
|
||||
if (type === "added") return t("diff.added");
|
||||
if (type === "removed") return t("diff.removed");
|
||||
return t("diff.modified");
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
step.value = "select";
|
||||
diffs.value = [];
|
||||
syncSql.value = "";
|
||||
}
|
||||
|
||||
watch(sourceConnectionId, (id) => {
|
||||
sourceDatabase.value = "";
|
||||
loadDatabases(id, "source");
|
||||
resetResult();
|
||||
});
|
||||
|
||||
watch(targetConnectionId, (id) => {
|
||||
targetDatabase.value = "";
|
||||
loadDatabases(id, "target");
|
||||
resetResult();
|
||||
});
|
||||
|
||||
watch(sourceDatabase, () => resetResult());
|
||||
watch(targetDatabase, () => resetResult());
|
||||
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
step.value = "select";
|
||||
diffs.value = [];
|
||||
syncSql.value = "";
|
||||
if (props.prefillConnectionId) {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
if (props.prefillDatabase) {
|
||||
sourceDatabase.value = props.prefillDatabase;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<GitCompareArrows class="w-4 h-4" />
|
||||
{{ t('diff.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
|
||||
<!-- Source / Target Selection -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t('diff.source') }}</Label>
|
||||
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => sourceConnectionId = String(v)">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-if="sourceDatabases.length" :model-value="sourceDatabase" @update:model-value="(v: any) => sourceDatabase = String(v)">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t('diff.target') }}</Label>
|
||||
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => targetConnectionId = String(v)">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-if="targetDatabases.length" :model-value="targetDatabase" @update:model-value="(v: any) => targetDatabase = String(v)">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button v-if="step === 'select'" size="sm" :disabled="!canCompare" @click="startCompare">
|
||||
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t('diff.compare') }}
|
||||
</Button>
|
||||
|
||||
<!-- Comparing -->
|
||||
<div v-if="step === 'comparing'" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 class="w-4 h-4 animate-spin mr-2" />
|
||||
{{ t('diff.comparing') }}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<template v-if="step === 'result'">
|
||||
<div v-if="diffs.length === 0" class="py-6 text-center text-sm text-muted-foreground">
|
||||
{{ t('diff.noDifferences') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Diff Table -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="max-h-60 overflow-auto">
|
||||
<table class="w-full text-xs table-fixed">
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium w-1/4">{{ t('diff.table') }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-16">{{ t('diff.status') }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium">{{ t('diff.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in diffs" :key="d.name" class="border-t border-border/50 hover:bg-accent/30">
|
||||
<td class="px-3 py-1.5 font-mono truncate">{{ d.name }}</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<Badge :variant="diffBadgeVariant(d.type)" class="text-[10px] h-4 px-1.5">
|
||||
{{ diffLabel(d.type) }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-muted-foreground">
|
||||
<template v-if="d.type === 'modified' && d.columns">
|
||||
<span v-for="(col, ci) in d.columns" :key="ci">
|
||||
<span :class="{
|
||||
'text-green-500': col.type === 'added',
|
||||
'text-red-500': col.type === 'removed',
|
||||
'text-yellow-500': col.type === 'modified',
|
||||
}">{{ col.type === 'added' ? '+' : col.type === 'removed' ? '-' : '~' }}{{ col.name }}</span>
|
||||
<span v-if="ci < d.columns!.length - 1">, </span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="d.type === 'added'" class="text-green-500">{{ t('diff.newTable') }}</span>
|
||||
<span v-else-if="d.type === 'removed'" class="text-red-500">{{ t('diff.dropTable') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL Preview -->
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">{{ t('diff.generatedSql') }}</Label>
|
||||
<textarea
|
||||
v-model="syncSql"
|
||||
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter v-if="step === 'result' && diffs.length > 0" class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="copySql">
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t('diff.copySql') }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!syncSql.trim() || executing" @click="executeSql">
|
||||
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<Play v-else class="w-3 h-3 mr-1" />
|
||||
{{ t('diff.executeSync') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -324,6 +324,15 @@ function openTransfer() {
|
||||
}
|
||||
}
|
||||
|
||||
function openSchemaDiff() {
|
||||
if (props.node.connectionId) {
|
||||
connectionStore.schemaDiffSource = {
|
||||
connectionId: props.node.connectionId,
|
||||
database: props.node.database ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const canExpand = !leafTypes.has(props.node.type);
|
||||
const canPin = computed(() => pinnableTypes.has(props.node.type));
|
||||
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
|
||||
@@ -459,6 +468,9 @@ async function showMore() {
|
||||
<ContextMenuItem @click="openTransfer">
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t('transfer.dataTransfer') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openSchemaDiff">
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t('diff.title') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'table' || node.type === 'view'">
|
||||
|
||||
@@ -295,4 +295,26 @@ export default {
|
||||
overallProgress: "Overall progress",
|
||||
dataTransfer: "Data Transfer",
|
||||
},
|
||||
diff: {
|
||||
title: "Compare Databases",
|
||||
source: "Source",
|
||||
target: "Target",
|
||||
selectConnection: "Select connection",
|
||||
selectDatabase: "Select database",
|
||||
compare: "Compare",
|
||||
comparing: "Comparing schemas...",
|
||||
noDifferences: "No differences found",
|
||||
table: "Table",
|
||||
status: "Status",
|
||||
details: "Details",
|
||||
added: "Added",
|
||||
removed: "Removed",
|
||||
modified: "Modified",
|
||||
newTable: "New table",
|
||||
dropTable: "Drop table",
|
||||
generatedSql: "Sync SQL",
|
||||
copySql: "Copy SQL",
|
||||
executeSync: "Execute Sync",
|
||||
syncSuccess: "Sync executed successfully",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -300,4 +300,26 @@ export default {
|
||||
overallProgress: "整体进度",
|
||||
dataTransfer: "数据传输",
|
||||
},
|
||||
diff: {
|
||||
title: "比较数据库",
|
||||
source: "源数据库",
|
||||
target: "目标数据库",
|
||||
selectConnection: "选择连接",
|
||||
selectDatabase: "选择数据库",
|
||||
compare: "开始比较",
|
||||
comparing: "正在比较结构...",
|
||||
noDifferences: "两个数据库结构完全一致",
|
||||
table: "表名",
|
||||
status: "状态",
|
||||
details: "详情",
|
||||
added: "新增",
|
||||
removed: "删除",
|
||||
modified: "修改",
|
||||
newTable: "新增表",
|
||||
dropTable: "删除表",
|
||||
generatedSql: "同步 SQL",
|
||||
copySql: "复制 SQL",
|
||||
executeSync: "执行同步",
|
||||
syncSuccess: "同步执行成功",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ColumnInfo, IndexInfo, DatabaseType } from "@/types/database";
|
||||
|
||||
export interface ColumnDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
name: string;
|
||||
source?: ColumnInfo;
|
||||
target?: ColumnInfo;
|
||||
changes?: string[];
|
||||
}
|
||||
|
||||
export interface IndexDiff {
|
||||
type: "added" | "removed";
|
||||
name: string;
|
||||
source?: IndexInfo;
|
||||
target?: IndexInfo;
|
||||
}
|
||||
|
||||
export interface TableDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
name: string;
|
||||
columns?: ColumnDiff[];
|
||||
indexes?: IndexDiff[];
|
||||
ddl?: string;
|
||||
}
|
||||
|
||||
export function diffColumns(
|
||||
source: ColumnInfo[],
|
||||
target: ColumnInfo[],
|
||||
): ColumnDiff[] {
|
||||
const diffs: ColumnDiff[] = [];
|
||||
const targetMap = new Map(target.map((c) => [c.name, c]));
|
||||
const sourceMap = new Map(source.map((c) => [c.name, c]));
|
||||
|
||||
for (const sc of source) {
|
||||
const tc = targetMap.get(sc.name);
|
||||
if (!tc) {
|
||||
diffs.push({ type: "added", name: sc.name, source: sc });
|
||||
} else {
|
||||
const changes: string[] = [];
|
||||
if (sc.data_type.toLowerCase() !== tc.data_type.toLowerCase()) {
|
||||
changes.push(`type: ${tc.data_type} → ${sc.data_type}`);
|
||||
}
|
||||
if (sc.is_nullable !== tc.is_nullable) {
|
||||
changes.push(`nullable: ${tc.is_nullable ? "YES" : "NO"} → ${sc.is_nullable ? "YES" : "NO"}`);
|
||||
}
|
||||
if ((sc.column_default ?? "") !== (tc.column_default ?? "")) {
|
||||
changes.push(`default: ${tc.column_default ?? "NULL"} → ${sc.column_default ?? "NULL"}`);
|
||||
}
|
||||
if (changes.length > 0) {
|
||||
diffs.push({ type: "modified", name: sc.name, source: sc, target: tc, changes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tc of target) {
|
||||
if (!sourceMap.has(tc.name)) {
|
||||
diffs.push({ type: "removed", name: tc.name, target: tc });
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
export function diffIndexes(
|
||||
source: IndexInfo[],
|
||||
target: IndexInfo[],
|
||||
): IndexDiff[] {
|
||||
const diffs: IndexDiff[] = [];
|
||||
const targetMap = new Map(target.map((i) => [i.name, i]));
|
||||
const sourceMap = new Map(source.map((i) => [i.name, i]));
|
||||
|
||||
for (const si of source) {
|
||||
if (si.is_primary) continue;
|
||||
if (!targetMap.has(si.name)) {
|
||||
diffs.push({ type: "added", name: si.name, source: si });
|
||||
}
|
||||
}
|
||||
|
||||
for (const ti of target) {
|
||||
if (ti.is_primary) continue;
|
||||
if (!sourceMap.has(ti.name)) {
|
||||
diffs.push({ type: "removed", name: ti.name, target: ti });
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
export function diffTables(
|
||||
sourceTables: string[],
|
||||
targetTables: string[],
|
||||
): { added: string[]; removed: string[]; common: string[] } {
|
||||
const targetSet = new Set(targetTables);
|
||||
const sourceSet = new Set(sourceTables);
|
||||
return {
|
||||
added: sourceTables.filter((t) => !targetSet.has(t)),
|
||||
removed: targetTables.filter((t) => !sourceSet.has(t)),
|
||||
common: sourceTables.filter((t) => targetSet.has(t)),
|
||||
};
|
||||
}
|
||||
|
||||
function quoteId(name: string, dbType: DatabaseType): string {
|
||||
if (dbType === "mysql" || dbType === "doris" || dbType === "starrocks") {
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function columnDef(col: ColumnInfo, dbType: DatabaseType): string {
|
||||
let def = `${quoteId(col.name, dbType)} ${col.data_type}`;
|
||||
if (!col.is_nullable) def += " NOT NULL";
|
||||
if (col.column_default !== null && col.column_default !== undefined) {
|
||||
def += ` DEFAULT ${col.column_default}`;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
export function generateSyncSql(
|
||||
diffs: TableDiff[],
|
||||
dbType: DatabaseType,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const isMySQL = dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
|
||||
|
||||
for (const diff of diffs) {
|
||||
const qt = quoteId(diff.name, dbType);
|
||||
|
||||
if (diff.type === "added" && diff.ddl) {
|
||||
lines.push(`-- Create table: ${diff.name}`);
|
||||
lines.push(diff.ddl + ";");
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diff.type === "removed") {
|
||||
lines.push(`-- Drop table: ${diff.name}`);
|
||||
lines.push(`DROP TABLE IF EXISTS ${qt};`);
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diff.type === "modified") {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (diff.columns) {
|
||||
for (const col of diff.columns) {
|
||||
if (col.type === "added" && col.source) {
|
||||
parts.push(` ADD COLUMN ${columnDef(col.source, dbType)}`);
|
||||
} else if (col.type === "removed") {
|
||||
parts.push(` DROP COLUMN ${quoteId(col.name, dbType)}`);
|
||||
} else if (col.type === "modified" && col.source) {
|
||||
if (isMySQL) {
|
||||
parts.push(` MODIFY COLUMN ${columnDef(col.source, dbType)}`);
|
||||
} else {
|
||||
const qn = quoteId(col.name, dbType);
|
||||
if (col.changes?.some((c) => c.startsWith("type:"))) {
|
||||
parts.push(` ALTER COLUMN ${qn} TYPE ${col.source.data_type}`);
|
||||
}
|
||||
if (col.changes?.some((c) => c.startsWith("nullable:"))) {
|
||||
parts.push(
|
||||
col.source.is_nullable
|
||||
? ` ALTER COLUMN ${qn} DROP NOT NULL`
|
||||
: ` ALTER COLUMN ${qn} SET NOT NULL`,
|
||||
);
|
||||
}
|
||||
if (col.changes?.some((c) => c.startsWith("default:"))) {
|
||||
if (col.source.column_default !== null && col.source.column_default !== undefined) {
|
||||
parts.push(` ALTER COLUMN ${qn} SET DEFAULT ${col.source.column_default}`);
|
||||
} else {
|
||||
parts.push(` ALTER COLUMN ${qn} DROP DEFAULT`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.indexes) {
|
||||
for (const idx of diff.indexes) {
|
||||
if (idx.type === "added" && idx.source) {
|
||||
const cols = idx.source.columns.map((c) => quoteId(c, dbType)).join(", ");
|
||||
const unique = idx.source.is_unique ? "UNIQUE " : "";
|
||||
lines.push(`CREATE ${unique}INDEX ${quoteId(idx.name, dbType)} ON ${qt} (${cols});`);
|
||||
} else if (idx.type === "removed") {
|
||||
if (isMySQL) {
|
||||
lines.push(`DROP INDEX ${quoteId(idx.name, dbType)} ON ${qt};`);
|
||||
} else {
|
||||
lines.push(`DROP INDEX IF EXISTS ${quoteId(idx.name, dbType)};`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
lines.push(`-- Alter table: ${diff.name}`);
|
||||
if (isMySQL) {
|
||||
lines.push(`ALTER TABLE ${qt}`);
|
||||
lines.push(parts.join(",\n") + ";");
|
||||
} else {
|
||||
for (const part of parts) {
|
||||
lines.push(`ALTER TABLE ${qt}${part};`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
|
||||
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
|
||||
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
|
||||
function startEditing(id: string) {
|
||||
editingConnectionId.value = id;
|
||||
@@ -645,5 +646,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
exportConnectionsToFile,
|
||||
importConnectionsFromFile,
|
||||
transferSource,
|
||||
schemaDiffSource,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user