feat(diff): add data compare and schema sync coverage

This commit is contained in:
t8y2
2026-05-09 23:44:49 +08:00
parent a878ec203a
commit 97b77ec46d
16 changed files with 1493 additions and 75 deletions
+575
View File
@@ -0,0 +1,575 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql";
import {
compareDataRows,
generateDataSyncSql,
generateDataSyncStatements,
type DataCompareResult,
} from "@/lib/dataCompare";
import * as api from "@/lib/api";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { Copy, GitCompareArrows, Loader2, Play } from "lucide-vue-next";
const { t } = useI18n();
const { toast } = useToast();
const store = useConnectionStore();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
}>();
const sourceConnectionId = ref("");
const sourceDatabase = ref("");
const sourceSchema = ref("");
const sourceTable = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
const sourceTables = ref<string[]>([]);
const targetConnectionId = ref("");
const targetDatabase = ref("");
const targetSchema = ref("");
const targetTable = ref("");
const targetDatabases = ref<string[]>([]);
const targetSchemas = ref<string[]>([]);
const targetTables = ref<string[]>([]);
const keyColumnsText = ref("");
const rowLimit = ref("1000");
const sourceRowCount = ref<number | null>(null);
const targetRowCount = ref<number | null>(null);
const result = ref<DataCompareResult | null>(null);
const syncSql = ref("");
const syncStatements = ref<string[]>([]);
const comparing = ref(false);
const executing = ref(false);
const rowLimitOptions = [1000, 5000, 10000, 50000];
const sqlConnections = computed(() =>
store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch"].includes(connection.db_type)),
);
const canCompare = computed(
() =>
sourceConnectionId.value &&
sourceDatabase.value &&
sourceSchema.value &&
sourceTable.value &&
targetConnectionId.value &&
targetDatabase.value &&
targetSchema.value &&
targetTable.value,
);
const keyColumns = computed(() =>
keyColumnsText.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
);
const summary = computed(() => {
const diff = result.value;
if (!diff) return "";
return t("dataCompare.summary", {
added: diff.added.length,
removed: diff.removed.length,
modified: diff.modified.length,
});
});
const rowLimitNumber = computed(() => Number(rowLimit.value) || 1000);
const isSourceTruncated = computed(() => sourceRowCount.value !== null && sourceRowCount.value > rowLimitNumber.value);
const isTargetTruncated = computed(() => targetRowCount.value !== null && targetRowCount.value > rowLimitNumber.value);
function connectionIconType(connectionId: string) {
const config = store.getConfig(connectionId);
return config?.driver_profile || config?.db_type || "mysql";
}
async function resolveSchema(connectionId: string, database: string, preferredSchema = ""): Promise<string> {
const config = store.getConfig(connectionId);
if (isSchemaAware(config?.db_type)) {
const schemas = await api.listSchemas(connectionId, database);
if (preferredSchema && schemas.includes(preferredSchema)) return preferredSchema;
return schemas.includes("public") ? "public" : (schemas[0] ?? "");
}
return database;
}
async function loadSchemas(side: "source" | "target", preferredSchema = "") {
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
if (!connectionId || !database) return;
const config = store.getConfig(connectionId);
if (!isSchemaAware(config?.db_type)) {
if (side === "source") {
sourceSchemas.value = [];
sourceSchema.value = database;
} else {
targetSchemas.value = [];
targetSchema.value = database;
}
await loadTables(side);
return;
}
const schemas = await api.listSchemas(connectionId, database);
const schema =
preferredSchema && schemas.includes(preferredSchema)
? preferredSchema
: schemas.includes("public")
? "public"
: (schemas[0] ?? "");
if (side === "source") {
sourceSchemas.value = schemas;
sourceSchema.value = schema;
} else {
targetSchemas.value = schemas;
targetSchema.value = schema;
}
}
async function loadDatabases(connectionId: string, side: "source" | "target") {
if (!connectionId) return;
await store.ensureConnected(connectionId);
const names = (await api.listDatabases(connectionId)).map((database) => database.name);
if (side === "source") {
sourceDatabases.value = names;
sourceDatabase.value = names.length === 1 ? names[0] : "";
sourceSchemas.value = [];
sourceSchema.value = "";
sourceTables.value = [];
sourceTable.value = "";
} else {
targetDatabases.value = names;
targetDatabase.value = names.length === 1 ? names[0] : "";
targetSchemas.value = [];
targetSchema.value = "";
targetTables.value = [];
targetTable.value = "";
}
}
async function loadTables(side: "source" | "target") {
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
if (!connectionId || !database) return;
const schema =
side === "source"
? sourceSchema.value || (await resolveSchema(connectionId, database, props.prefillSchema))
: targetSchema.value || (await resolveSchema(connectionId, database));
const tables = (await api.listTables(connectionId, database, schema))
.filter((table) => table.table_type !== "VIEW")
.map((table) => table.name);
if (side === "source") {
sourceSchema.value = schema;
sourceTables.value = tables;
const preferred =
props.prefillTable && tables.includes(props.prefillTable) ? props.prefillTable : sourceTable.value;
sourceTable.value = tables.includes(preferred) ? preferred : "";
} else {
targetSchema.value = schema;
targetTables.value = tables;
const preferred = sourceTable.value && tables.includes(sourceTable.value) ? sourceTable.value : targetTable.value;
targetTable.value = tables.includes(preferred) ? preferred : "";
}
}
function clearResult() {
result.value = null;
syncSql.value = "";
syncStatements.value = [];
sourceRowCount.value = null;
targetRowCount.value = null;
}
async function countTableRows(connectionId: string, database: string, schema: string, tableName: string) {
const config = store.getConfig(connectionId);
const table = qualifiedTableName({ databaseType: config?.db_type, schema, tableName });
const result = await api.executeQuery(connectionId, database, `SELECT COUNT(*) AS row_count FROM ${table}`, schema);
return Number(result.rows[0]?.[0] ?? 0);
}
async function inferKeyColumns() {
if (!sourceConnectionId.value || !sourceDatabase.value || !sourceTable.value) return;
const columns = await api.getColumns(
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
sourceTable.value,
);
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
keyColumnsText.value = (primaryKeys.length ? primaryKeys : columns.slice(0, 1).map((column) => column.name)).join(
", ",
);
}
async function startCompare() {
if (!canCompare.value || comparing.value) return;
comparing.value = true;
clearResult();
try {
await Promise.all([
store.ensureConnected(sourceConnectionId.value),
store.ensureConnected(targetConnectionId.value),
]);
if (keyColumns.value.length === 0) await inferKeyColumns();
if (keyColumns.value.length === 0) throw new Error(t("dataCompare.noKeyColumns"));
const sourceConfig = store.getConfig(sourceConnectionId.value);
const targetConfig = store.getConfig(targetConnectionId.value);
const sourceColumns = await api.getColumns(
sourceConnectionId.value,
sourceDatabase.value,
sourceSchema.value,
sourceTable.value,
);
const targetColumns = await api.getColumns(
targetConnectionId.value,
targetDatabase.value,
targetSchema.value,
targetTable.value,
);
const columns = sourceColumns
.map((column) => column.name)
.filter((column) => targetColumns.some((target) => target.name === column));
const missingKeys = keyColumns.value.filter((column) => !columns.includes(column));
if (missingKeys.length > 0) {
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
}
if (columns.length === 0) throw new Error(t("dataCompare.noCommonColumns"));
const [srcCount, tgtCount] = await Promise.all([
countTableRows(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, sourceTable.value),
countTableRows(targetConnectionId.value, targetDatabase.value, targetSchema.value, targetTable.value),
]);
sourceRowCount.value = srcCount;
targetRowCount.value = tgtCount;
const sourceSql = buildTableSelectSql({
databaseType: sourceConfig?.db_type,
schema: sourceSchema.value,
tableName: sourceTable.value,
primaryKeys: keyColumns.value,
limit: rowLimitNumber.value,
});
const targetSql = buildTableSelectSql({
databaseType: targetConfig?.db_type,
schema: targetSchema.value,
tableName: targetTable.value,
primaryKeys: keyColumns.value,
limit: rowLimitNumber.value,
});
const [sourceResult, targetResult] = await Promise.all([
api.executeQuery(sourceConnectionId.value, sourceDatabase.value, sourceSql, sourceSchema.value),
api.executeQuery(targetConnectionId.value, targetDatabase.value, targetSql, targetSchema.value),
]);
const diff = compareDataRows({
columns,
keyColumns: keyColumns.value,
sourceRows: sourceResult.rows,
targetRows: targetResult.rows,
});
result.value = diff;
const syncOptions = {
tableName: targetTable.value,
schema: targetSchema.value,
columns,
keyColumns: keyColumns.value,
diff,
databaseType: targetConfig?.db_type,
};
syncStatements.value = generateDataSyncStatements(syncOptions);
syncSql.value = generateDataSyncSql(syncOptions);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
comparing.value = false;
}
}
async function copySql() {
await navigator.clipboard.writeText(syncSql.value);
toast(t("grid.copied"));
}
async function executeSql() {
if (!syncSql.value.trim() || syncStatements.value.length === 0 || executing.value) return;
executing.value = true;
try {
await api.executeInTransaction(
targetConnectionId.value,
targetDatabase.value,
syncStatements.value,
targetSchema.value,
);
toast(t("dataCompare.syncSuccess"), 2000);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
executing.value = false;
}
}
watch(sourceConnectionId, (id) => {
clearResult();
sourceDatabase.value = "";
sourceSchema.value = "";
sourceSchemas.value = [];
sourceTables.value = [];
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
});
watch(targetConnectionId, (id) => {
clearResult();
targetDatabase.value = "";
targetSchema.value = "";
targetSchemas.value = [];
targetTables.value = [];
loadDatabases(id, "target").catch((e) => toast(String(e), 5000));
});
watch(sourceDatabase, () => {
clearResult();
sourceSchema.value = "";
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(targetDatabase, () => {
clearResult();
targetSchema.value = "";
targetSchemas.value = [];
targetTables.value = [];
targetTable.value = "";
loadSchemas("target").catch((e) => toast(String(e), 5000));
});
watch(sourceSchema, () => {
clearResult();
sourceTables.value = [];
sourceTable.value = "";
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
});
watch(targetSchema, () => {
clearResult();
targetTables.value = [];
targetTable.value = "";
if (targetSchema.value) loadTables("target").catch((e) => toast(String(e), 5000));
});
watch(sourceTable, (table, previous) => {
clearResult();
if (table !== previous) keyColumnsText.value = "";
if (table && targetTables.value.includes(table)) targetTable.value = table;
if (sourceTable.value) {
inferKeyColumns().catch(() => {});
}
});
watch(targetTable, () => clearResult());
watch(open, async (value) => {
if (!value) return;
result.value = null;
syncSql.value = "";
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
if (props.prefillDatabase) await loadSchemas("source", props.prefillSchema);
if (props.prefillTable) {
await loadTables("source");
if (sourceTables.value.includes(props.prefillTable)) sourceTable.value = props.prefillTable;
}
}
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<GitCompareArrows class="w-4 h-4" />
{{ t("dataCompare.title") }}
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
<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="connection in sqlConnections" :key="connection.id" :value="connection.id">
{{ connection.name }}
</SelectItem>
</SelectContent>
</Select>
<Select :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="database in sourceDatabases" :key="database" :value="database">{{
database
}}</SelectItem>
</SelectContent>
</Select>
<Select
v-if="sourceSchemas.length"
:model-value="sourceSchema"
@update:model-value="(v: any) => (sourceSchema = String(v))"
>
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectSchema')" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
<Select :model-value="sourceTable" @update:model-value="(v: any) => (sourceTable = String(v))">
<SelectTrigger class="h-8 text-xs"
><SelectValue :placeholder="t('dataCompare.selectTable')"
/></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in sourceTables" :key="table" :value="table">{{ table }}</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="connection in sqlConnections" :key="connection.id" :value="connection.id">
{{ connection.name }}
</SelectItem>
</SelectContent>
</Select>
<Select :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="database in targetDatabases" :key="database" :value="database">{{
database
}}</SelectItem>
</SelectContent>
</Select>
<Select
v-if="targetSchemas.length"
:model-value="targetSchema"
@update:model-value="(v: any) => (targetSchema = String(v))"
>
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectSchema')" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
<Select :model-value="targetTable" @update:model-value="(v: any) => (targetTable = String(v))">
<SelectTrigger class="h-8 text-xs"
><SelectValue :placeholder="t('dataCompare.selectTable')"
/></SelectTrigger>
<SelectContent>
<SelectItem v-for="table in targetTables" :key="table" :value="table">{{ table }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
<input
v-model="keyColumnsText"
class="h-8 w-full rounded-md border bg-background px-2 text-xs"
:placeholder="t('dataCompare.keyColumnsPlaceholder')"
/>
</div>
<div class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.rowLimit") }}</Label>
<Select v-model="rowLimit">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="limit in rowLimitOptions" :key="limit" :value="String(limit)">
{{ t("dataCompare.rowLimitOption", { count: limit }) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
<GitCompareArrows v-else class="w-3.5 h-3.5 mr-1" />
{{ t("dataCompare.compare") }}
</Button>
<div v-if="result" class="rounded-lg border p-3 text-sm">
{{ summary }}
<div v-if="sourceRowCount !== null && targetRowCount !== null" class="mt-1 text-xs text-muted-foreground">
{{
t("dataCompare.rowCounts", {
source: sourceRowCount,
target: targetRowCount,
limit: rowLimitNumber,
})
}}
</div>
<div v-if="isSourceTruncated || isTargetTruncated" class="mt-1 text-xs text-yellow-600">
{{ t("dataCompare.truncatedWarning") }}
</div>
</div>
<div v-if="result && syncSql.trim()" 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>
<div v-else-if="result" class="text-sm text-muted-foreground">
{{ t("dataCompare.noDifferences") }}
</div>
</div>
<DialogFooter v-if="result && syncSql.trim()" 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="executing || syncStatements.length === 0" @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>
+173 -32
View File
@@ -10,7 +10,15 @@ import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { diffColumns, diffIndexes, diffTables, generateSyncSql, type TableDiff } from "@/lib/schemaDiff";
import {
diffColumns,
diffForeignKeys,
diffIndexes,
diffTables,
diffTriggers,
generateSyncSql,
type TableDiff,
} from "@/lib/schemaDiff";
import { useToast } from "@/composables/useToast";
import { Loader2, Copy, Play, GitCompareArrows } from "lucide-vue-next";
@@ -22,17 +30,20 @@ const store = useConnectionStore();
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
}>();
const sourceConnectionId = ref("");
const sourceDatabase = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchema = ref("");
const sourceSchemas = ref<string[]>([]);
const targetConnectionId = ref("");
const targetDatabase = ref("");
const targetDatabases = ref<string[]>([]);
const targetSchema = ref("");
const targetSchemas = ref<string[]>([]);
const step = ref<"select" | "comparing" | "result">("select");
const diffs = ref<TableDiff[]>([]);
@@ -44,7 +55,13 @@ const sqlConnections = computed(() =>
);
const canCompare = computed(
() => sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value,
() =>
sourceConnectionId.value &&
sourceDatabase.value &&
sourceSchema.value &&
targetConnectionId.value &&
targetDatabase.value &&
targetSchema.value,
);
function connectionIconType(connectionId: string) {
@@ -61,9 +78,13 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
if (side === "source") {
sourceDatabases.value = names;
sourceDatabase.value = names.length === 1 ? names[0] : "";
sourceSchemas.value = [];
sourceSchema.value = "";
} else {
targetDatabases.value = names;
targetDatabase.value = names.length === 1 ? names[0] : "";
targetSchemas.value = [];
targetSchema.value = "";
}
} catch {
if (side === "source") sourceDatabases.value = [];
@@ -71,14 +92,36 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
}
}
async function resolveSchema(connectionId: string, database: string): Promise<string> {
async function loadSchemas(side: "source" | "target", preferredSchema = "") {
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
if (!connectionId || !database) return;
const config = store.getConfig(connectionId);
const needsSchema = isSchemaAware(config?.db_type);
if (needsSchema) {
const schemas = await api.listSchemas(connectionId, database);
return schemas.includes("public") ? "public" : (schemas[0] ?? "");
if (!isSchemaAware(config?.db_type)) {
if (side === "source") {
sourceSchemas.value = [];
sourceSchema.value = database;
} else {
targetSchemas.value = [];
targetSchema.value = database;
}
return;
}
const schemas = await api.listSchemas(connectionId, database);
const selected =
preferredSchema && schemas.includes(preferredSchema)
? preferredSchema
: schemas.includes("public")
? "public"
: (schemas[0] ?? "");
if (side === "source") {
sourceSchemas.value = schemas;
sourceSchema.value = selected;
} else {
targetSchemas.value = schemas;
targetSchema.value = selected;
}
return database;
}
async function startCompare() {
@@ -91,55 +134,70 @@ async function startCompare() {
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),
api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value),
api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value),
]);
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 srcTableNames = srcTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name);
const tgtTableNames = tgtTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name);
const srcViewNames = srcTables.filter((t) => t.table_type === "VIEW").map((t) => t.name);
const tgtViewNames = tgtTables.filter((t) => t.table_type === "VIEW").map((t) => t.name);
const { added, removed, common } = diffTables(srcTableNames, tgtTableNames);
const { added: addedViews, removed: removedViews } = diffTables(srcViewNames, tgtViewNames);
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 });
const ddl = await api.getTableDdl(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name);
result.push({ type: "added", objectType: "table", name, ddl });
}
for (const name of removed) {
result.push({ type: "removed", name });
result.push({ type: "removed", objectType: "table", name });
}
for (const name of addedViews) {
result.push({ type: "added", objectType: "view", name });
}
for (const name of removedViews) {
result.push({ type: "removed", objectType: "view", 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 [srcCols, tgtCols, srcIdx, tgtIdx, srcFks, tgtFks, srcTriggers, tgtTriggers] = await Promise.all([
api.getColumns(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name),
api.getColumns(targetConnectionId.value, targetDatabase.value, targetSchema.value, name),
api.listIndexes(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name),
api.listIndexes(targetConnectionId.value, targetDatabase.value, targetSchema.value, name),
api.listForeignKeys(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name),
api.listForeignKeys(targetConnectionId.value, targetDatabase.value, targetSchema.value, name),
api.listTriggers(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name),
api.listTriggers(targetConnectionId.value, targetDatabase.value, targetSchema.value, name),
]);
const colDiffs = diffColumns(srcCols, tgtCols);
const idxDiffs = diffIndexes(srcIdx, tgtIdx);
const fkDiffs = diffForeignKeys(srcFks, tgtFks);
const triggerDiffs = diffTriggers(srcTriggers, tgtTriggers);
if (colDiffs.length > 0 || idxDiffs.length > 0) {
if (colDiffs.length > 0 || idxDiffs.length > 0 || fkDiffs.length > 0 || triggerDiffs.length > 0) {
result.push({
type: "modified",
objectType: "table",
name,
columns: colDiffs.length > 0 ? colDiffs : undefined,
indexes: idxDiffs.length > 0 ? idxDiffs : undefined,
foreignKeys: fkDiffs.length > 0 ? fkDiffs : undefined,
triggers: triggerDiffs.length > 0 ? triggerDiffs : undefined,
});
}
}
diffs.value = result;
const srcConfig = store.getConfig(targetConnectionId.value);
syncSql.value = generateSyncSql(result, srcConfig?.db_type || "mysql");
syncSql.value = generateSyncSql(result, srcConfig?.db_type || "mysql", targetSchema.value);
step.value = "result";
} catch (e: any) {
toast(e?.message || String(e), 5000);
@@ -152,7 +210,7 @@ async function executeSql() {
executing.value = true;
try {
await store.ensureConnected(targetConnectionId.value);
await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value);
await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value, targetSchema.value);
toast(t("diff.syncSuccess"), 2000);
open.value = false;
} catch (e: any) {
@@ -197,8 +255,20 @@ watch(targetConnectionId, (id) => {
resetResult();
});
watch(sourceDatabase, () => resetResult());
watch(targetDatabase, () => resetResult());
watch(sourceDatabase, (database) => {
sourceSchema.value = "";
sourceSchemas.value = [];
resetResult();
if (database) loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(targetDatabase, (database) => {
targetSchema.value = "";
targetSchemas.value = [];
resetResult();
if (database) loadSchemas("target").catch((e) => toast(String(e), 5000));
});
watch(sourceSchema, () => resetResult());
watch(targetSchema, () => resetResult());
watch(open, async (val) => {
if (val) {
@@ -210,6 +280,7 @@ watch(open, async (val) => {
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) {
sourceDatabase.value = props.prefillDatabase;
await loadSchemas("source", props.prefillSchema);
}
}
}
@@ -266,6 +337,18 @@ watch(open, async (val) => {
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
<Select
v-if="sourceSchemas.length"
:model-value="sourceSchema"
@update:model-value="(v: any) => (sourceSchema = String(v))"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
@@ -305,6 +388,18 @@ watch(open, async (val) => {
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
<Select
v-if="targetSchemas.length"
:model-value="targetSchema"
@update:model-value="(v: any) => (targetSchema = String(v))"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
@@ -347,7 +442,7 @@ watch(open, async (val) => {
</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 v-for="(col, ci) in d.columns" :key="`col-${ci}`">
<span
:class="{
'text-green-500': col.type === 'added',
@@ -359,6 +454,52 @@ watch(open, async (val) => {
<span v-if="ci < d.columns!.length - 1">, </span>
</span>
</template>
<template v-if="d.type === 'modified' && d.indexes">
<span v-if="d.columns?.length">; </span>
<span>{{ t("diff.indexes") }}: </span>
<span v-for="(idx, ii) in d.indexes" :key="`idx-${ii}`">
<span
:class="{
'text-green-500': idx.type === 'added',
'text-red-500': idx.type === 'removed',
'text-yellow-500': idx.type === 'modified',
}"
>{{ idx.type === "added" ? "+" : idx.type === "removed" ? "-" : "~" }}{{ idx.name }}</span
>
<span v-if="ii < d.indexes!.length - 1">, </span>
</span>
</template>
<template v-if="d.type === 'modified' && d.foreignKeys">
<span v-if="d.columns?.length || d.indexes?.length">; </span>
<span>{{ t("diff.foreignKeys") }}: </span>
<span v-for="(fk, fi) in d.foreignKeys" :key="`fk-${fi}`">
<span
:class="{
'text-green-500': fk.type === 'added',
'text-red-500': fk.type === 'removed',
'text-yellow-500': fk.type === 'modified',
}"
>{{ fk.type === "added" ? "+" : fk.type === "removed" ? "-" : "~" }}{{ fk.name }}</span
>
<span v-if="fi < d.foreignKeys!.length - 1">, </span>
</span>
</template>
<template v-if="d.type === 'modified' && d.triggers">
<span v-if="d.columns?.length || d.indexes?.length || d.foreignKeys?.length">; </span>
<span>{{ t("diff.triggers") }}: </span>
<span v-for="(trigger, ti) in d.triggers" :key="`trigger-${ti}`">
<span
:class="{
'text-green-500': trigger.type === 'added',
'text-red-500': trigger.type === 'removed',
'text-yellow-500': trigger.type === 'modified',
}"
>{{ trigger.type === "added" ? "+" : trigger.type === "removed" ? "-" : "~"
}}{{ trigger.name }}</span
>
<span v-if="ti < d.triggers!.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>
+9
View File
@@ -8,6 +8,7 @@ import EditorSettingsDialog from "@/components/editor/EditorSettingsDialog.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue"));
const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue"));
const DataCompareDialog = defineAsyncComponent(() => import("@/components/diff/DataCompareDialog.vue"));
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
@@ -113,6 +114,14 @@ watch(
v-model:open="dialogs.showSchemaDiffDialog.value"
:prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value"
:prefill-database="dialogs.schemaDiffPrefillDatabase.value"
:prefill-schema="dialogs.schemaDiffPrefillSchema.value"
/>
<DataCompareDialog
v-model:open="dialogs.showDataCompareDialog.value"
:prefill-connection-id="dialogs.dataComparePrefillConnectionId.value"
:prefill-database="dialogs.dataComparePrefillDatabase.value"
:prefill-schema="dialogs.dataComparePrefillSchema.value"
:prefill-table="dialogs.dataComparePrefillTable.value"
/>
<SqlFileExecutionDialog
v-model:open="dialogs.showSqlFileDialog.value"
+18
View File
@@ -882,6 +882,18 @@ function openSchemaDiff() {
connectionStore.schemaDiffSource = {
connectionId: props.node.connectionId,
database: props.node.database ?? "",
schema: props.node.schema,
};
}
}
function openDataCompare() {
if (props.node.connectionId) {
connectionStore.dataCompareSource = {
connectionId: props.node.connectionId,
database: props.node.database ?? "",
schema: props.node.schema,
tableName: props.node.type === "table" ? props.node.label : undefined,
};
}
}
@@ -1393,6 +1405,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<ContextMenuItem @click="openSchemaDiff">
<ArrowRightLeft class="w-4 h-4" /> {{ t("diff.title") }}
</ContextMenuItem>
<ContextMenuItem @click="openDataCompare">
<ArrowRightLeft class="w-4 h-4" /> {{ t("dataCompare.title") }}
</ContextMenuItem>
<ContextMenuItem :disabled="isExportingDatabase" @click="exportDatabase">
<Loader2 v-if="isExportingDatabase" class="w-4 h-4 animate-spin" />
<Download v-else class="w-4 h-4" />
@@ -1437,6 +1452,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<ContextMenuItem v-if="canOpenTableImport" @click="openTableImport">
<FileUp class="w-4 h-4" /> {{ t("contextMenu.importData") }}
</ContextMenuItem>
<ContextMenuItem v-if="isTableNotView" @click="openDataCompare">
<ArrowRightLeft class="w-4 h-4" /> {{ t("dataCompare.title") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>