feat(diff): add data compare and schema sync coverage
This commit is contained in:
parent
a878ec203a
commit
97b77ec46d
|
|
@ -57,6 +57,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@oxfmt/binding-darwin-arm64": "0.47.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.62.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"@types/node": "^25.6.0",
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@ importers:
|
|||
'@oxfmt/binding-darwin-arm64':
|
||||
specifier: 0.47.0
|
||||
version: 0.47.0
|
||||
'@oxlint/binding-darwin-arm64':
|
||||
specifier: 1.62.0
|
||||
version: 1.62.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4))
|
||||
|
|
@ -3684,8 +3687,7 @@ snapshots:
|
|||
'@oxlint/binding-android-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.62.0':
|
||||
optional: true
|
||||
'@oxlint/binding-darwin-arm64@1.62.0': {}
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.62.0':
|
||||
optional: true
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { SidebarLayout } from "@/types/database";
|
|||
|
||||
const showTransferDialog = ref(false);
|
||||
const showSchemaDiffDialog = ref(false);
|
||||
const showDataCompareDialog = ref(false);
|
||||
const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
|
|
@ -23,6 +24,11 @@ const transferPrefillConnectionId = ref("");
|
|||
const transferPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillConnectionId = ref("");
|
||||
const schemaDiffPrefillDatabase = ref("");
|
||||
const schemaDiffPrefillSchema = ref("");
|
||||
const dataComparePrefillConnectionId = ref("");
|
||||
const dataComparePrefillDatabase = ref("");
|
||||
const dataComparePrefillSchema = ref("");
|
||||
const dataComparePrefillTable = ref("");
|
||||
const sqlFilePrefillConnectionId = ref("");
|
||||
const sqlFilePrefillDatabase = ref("");
|
||||
const diagramPrefillConnectionId = ref("");
|
||||
|
|
@ -75,12 +81,27 @@ export function useDialogSources() {
|
|||
if (v) {
|
||||
schemaDiffPrefillConnectionId.value = v.connectionId;
|
||||
schemaDiffPrefillDatabase.value = v.database;
|
||||
schemaDiffPrefillSchema.value = v.schema ?? "";
|
||||
showSchemaDiffDialog.value = true;
|
||||
connectionStore.schemaDiffSource = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.dataCompareSource,
|
||||
(v) => {
|
||||
if (v) {
|
||||
dataComparePrefillConnectionId.value = v.connectionId;
|
||||
dataComparePrefillDatabase.value = v.database;
|
||||
dataComparePrefillSchema.value = v.schema ?? "";
|
||||
dataComparePrefillTable.value = v.tableName ?? "";
|
||||
showDataCompareDialog.value = true;
|
||||
connectionStore.dataCompareSource = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.sqlFileSource,
|
||||
(v) => {
|
||||
|
|
@ -221,6 +242,7 @@ export function useDialogSources() {
|
|||
return {
|
||||
showTransferDialog,
|
||||
showSchemaDiffDialog,
|
||||
showDataCompareDialog,
|
||||
showSqlFileDialog,
|
||||
showDiagramDialog,
|
||||
showTableImportDialog,
|
||||
|
|
@ -237,6 +259,11 @@ export function useDialogSources() {
|
|||
transferPrefillDatabase,
|
||||
schemaDiffPrefillConnectionId,
|
||||
schemaDiffPrefillDatabase,
|
||||
schemaDiffPrefillSchema,
|
||||
dataComparePrefillConnectionId,
|
||||
dataComparePrefillDatabase,
|
||||
dataComparePrefillSchema,
|
||||
dataComparePrefillTable,
|
||||
sqlFilePrefillConnectionId,
|
||||
sqlFilePrefillDatabase,
|
||||
diagramPrefillConnectionId,
|
||||
|
|
|
|||
|
|
@ -714,6 +714,7 @@ export default {
|
|||
target: "Target",
|
||||
selectConnection: "Select connection",
|
||||
selectDatabase: "Select database",
|
||||
selectSchema: "Select schema",
|
||||
compare: "Compare",
|
||||
comparing: "Comparing schemas...",
|
||||
noDifferences: "No differences found",
|
||||
|
|
@ -723,6 +724,9 @@ export default {
|
|||
added: "Added",
|
||||
removed: "Removed",
|
||||
modified: "Modified",
|
||||
indexes: "Indexes",
|
||||
foreignKeys: "Foreign keys",
|
||||
triggers: "Triggers",
|
||||
newTable: "New table",
|
||||
dropTable: "Drop table",
|
||||
generatedSql: "Sync SQL",
|
||||
|
|
@ -730,6 +734,24 @@ export default {
|
|||
executeSync: "Execute Sync",
|
||||
syncSuccess: "Sync executed successfully",
|
||||
},
|
||||
dataCompare: {
|
||||
title: "Compare Data",
|
||||
selectTable: "Select table",
|
||||
keyColumns: "Key Columns",
|
||||
keyColumnsPlaceholder: "Comma-separated primary or unique columns",
|
||||
rowLimit: "Compare row limit",
|
||||
rowLimitOption: "{count} rows",
|
||||
rowCounts: "Source {source} rows, target {target} rows; comparing up to the first {limit} rows",
|
||||
truncatedWarning:
|
||||
"The table exceeds this compare limit, so results only cover loaded rows. Increase the limit or use chunked compare later.",
|
||||
compare: "Compare Data",
|
||||
summary: "Added {added}, removed {removed}, modified {modified}",
|
||||
noKeyColumns: "Select at least one key column",
|
||||
missingKeyColumns: "Key columns must exist in both source and target tables: {columns}",
|
||||
noCommonColumns: "Source and target tables do not have common columns to compare",
|
||||
noDifferences: "Data is identical, no sync SQL needed",
|
||||
syncSuccess: "Data sync executed successfully",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
editorTab: "Editor",
|
||||
|
|
|
|||
|
|
@ -702,6 +702,7 @@ export default {
|
|||
target: "目标数据库",
|
||||
selectConnection: "选择连接",
|
||||
selectDatabase: "选择数据库",
|
||||
selectSchema: "选择 Schema",
|
||||
compare: "开始比较",
|
||||
comparing: "正在比较结构...",
|
||||
noDifferences: "两个数据库结构完全一致",
|
||||
|
|
@ -711,6 +712,9 @@ export default {
|
|||
added: "新增",
|
||||
removed: "删除",
|
||||
modified: "修改",
|
||||
indexes: "索引",
|
||||
foreignKeys: "外键",
|
||||
triggers: "触发器",
|
||||
newTable: "新增表",
|
||||
dropTable: "删除表",
|
||||
generatedSql: "同步 SQL",
|
||||
|
|
@ -718,6 +722,23 @@ export default {
|
|||
executeSync: "执行同步",
|
||||
syncSuccess: "同步执行成功",
|
||||
},
|
||||
dataCompare: {
|
||||
title: "比较数据",
|
||||
selectTable: "选择表",
|
||||
keyColumns: "匹配字段",
|
||||
keyColumnsPlaceholder: "用逗号分隔主键或唯一键字段",
|
||||
rowLimit: "比较行数上限",
|
||||
rowLimitOption: "{count} 行",
|
||||
rowCounts: "源表 {source} 行,目标表 {target} 行;本次最多比较前 {limit} 行",
|
||||
truncatedWarning: "表数据超过本次比较上限,结果只代表已加载范围。建议提高上限或后续使用分块比较。",
|
||||
compare: "开始比较数据",
|
||||
summary: "新增 {added} 行,删除 {removed} 行,修改 {modified} 行",
|
||||
noKeyColumns: "请至少选择一个匹配字段",
|
||||
missingKeyColumns: "匹配字段在源表和目标表中都必须存在:{columns}",
|
||||
noCommonColumns: "源表和目标表没有可比较的同名字段",
|
||||
noDifferences: "数据完全一致,无需生成同步 SQL",
|
||||
syncSuccess: "数据同步执行成功",
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
editorTab: "编辑器",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import { quoteTableIdentifier } from "./tableSelectSql";
|
||||
import { formatGridSqlLiteral } from "./dataGridSql";
|
||||
|
||||
export type DataCompareCellValue = QueryResult["rows"][number][number];
|
||||
|
||||
export interface DataCompareChangedCell {
|
||||
column: string;
|
||||
source: DataCompareCellValue;
|
||||
target: DataCompareCellValue;
|
||||
}
|
||||
|
||||
export interface DataCompareRow {
|
||||
key: string;
|
||||
keyValues: Record<string, DataCompareCellValue>;
|
||||
values: Record<string, DataCompareCellValue>;
|
||||
}
|
||||
|
||||
export interface DataCompareModifiedRow {
|
||||
key: string;
|
||||
keyValues: Record<string, DataCompareCellValue>;
|
||||
sourceValues: Record<string, DataCompareCellValue>;
|
||||
targetValues: Record<string, DataCompareCellValue>;
|
||||
changes: DataCompareChangedCell[];
|
||||
}
|
||||
|
||||
export interface DataCompareResult {
|
||||
added: DataCompareRow[];
|
||||
removed: DataCompareRow[];
|
||||
modified: DataCompareModifiedRow[];
|
||||
}
|
||||
|
||||
export interface CompareDataRowsOptions {
|
||||
columns: readonly string[];
|
||||
keyColumns: readonly string[];
|
||||
sourceRows: readonly (readonly DataCompareCellValue[])[];
|
||||
targetRows: readonly (readonly DataCompareCellValue[])[];
|
||||
}
|
||||
|
||||
export interface GenerateDataSyncSqlOptions {
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
columns: readonly string[];
|
||||
keyColumns: readonly string[];
|
||||
diff: DataCompareResult;
|
||||
databaseType?: DatabaseType;
|
||||
}
|
||||
|
||||
function rowObject(
|
||||
columns: readonly string[],
|
||||
row: readonly DataCompareCellValue[],
|
||||
): Record<string, DataCompareCellValue> {
|
||||
const item: Record<string, DataCompareCellValue> = {};
|
||||
columns.forEach((column, index) => {
|
||||
item[column] = row[index] ?? null;
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
function keyFor(row: Record<string, DataCompareCellValue>, keyColumns: readonly string[]): string {
|
||||
return keyColumns.map((column) => JSON.stringify(row[column] ?? null)).join("\u001f");
|
||||
}
|
||||
|
||||
function keyValues(row: Record<string, DataCompareCellValue>, keyColumns: readonly string[]) {
|
||||
const values: Record<string, DataCompareCellValue> = {};
|
||||
keyColumns.forEach((column) => {
|
||||
values[column] = row[column] ?? null;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
export function compareDataRows(options: CompareDataRowsOptions): DataCompareResult {
|
||||
if (options.keyColumns.length === 0) {
|
||||
throw new Error("At least one key column is required for data comparison");
|
||||
}
|
||||
|
||||
const source = new Map<string, Record<string, DataCompareCellValue>>();
|
||||
const target = new Map<string, Record<string, DataCompareCellValue>>();
|
||||
options.sourceRows.forEach((row) => {
|
||||
const item = rowObject(options.columns, row);
|
||||
const key = keyFor(item, options.keyColumns);
|
||||
if (source.has(key)) throw new Error(`Duplicate source key: ${key}`);
|
||||
source.set(key, item);
|
||||
});
|
||||
options.targetRows.forEach((row) => {
|
||||
const item = rowObject(options.columns, row);
|
||||
const key = keyFor(item, options.keyColumns);
|
||||
if (target.has(key)) throw new Error(`Duplicate target key: ${key}`);
|
||||
target.set(key, item);
|
||||
});
|
||||
|
||||
const added: DataCompareRow[] = [];
|
||||
const removed: DataCompareRow[] = [];
|
||||
const modified: DataCompareModifiedRow[] = [];
|
||||
|
||||
for (const [key, sourceValues] of source) {
|
||||
const targetValues = target.get(key);
|
||||
if (!targetValues) {
|
||||
added.push({ key, keyValues: keyValues(sourceValues, options.keyColumns), values: sourceValues });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes = options.columns
|
||||
.filter((column) => !options.keyColumns.includes(column))
|
||||
.filter((column) => sourceValues[column] !== targetValues[column])
|
||||
.map((column) => ({ column, source: sourceValues[column] ?? null, target: targetValues[column] ?? null }));
|
||||
|
||||
if (changes.length > 0) {
|
||||
modified.push({
|
||||
key,
|
||||
keyValues: keyValues(sourceValues, options.keyColumns),
|
||||
sourceValues,
|
||||
targetValues,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, targetValues] of target) {
|
||||
if (!source.has(key)) {
|
||||
removed.push({ key, keyValues: keyValues(targetValues, options.keyColumns), values: targetValues });
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed, modified };
|
||||
}
|
||||
|
||||
function qualifiedTableName(schema: string | undefined, tableName: string, databaseType?: DatabaseType): string {
|
||||
const table = quoteTableIdentifier(databaseType, tableName);
|
||||
return schema ? `${quoteTableIdentifier(databaseType, schema)}.${table}` : table;
|
||||
}
|
||||
|
||||
function whereByKey(
|
||||
keyValues: Record<string, DataCompareCellValue>,
|
||||
keyColumns: readonly string[],
|
||||
databaseType?: DatabaseType,
|
||||
): string {
|
||||
return keyColumns
|
||||
.map(
|
||||
(column) =>
|
||||
`${quoteTableIdentifier(databaseType, column)} = ${formatGridSqlLiteral(keyValues[column], databaseType)}`,
|
||||
)
|
||||
.join(" AND ");
|
||||
}
|
||||
|
||||
export function generateDataSyncStatements(options: GenerateDataSyncSqlOptions): string[] {
|
||||
const table = qualifiedTableName(options.schema, options.tableName, options.databaseType);
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const row of options.diff.added) {
|
||||
const columns = options.columns.map((column) => quoteTableIdentifier(options.databaseType, column)).join(", ");
|
||||
const values = options.columns
|
||||
.map((column) => formatGridSqlLiteral(row.values[column], options.databaseType))
|
||||
.join(", ");
|
||||
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
|
||||
}
|
||||
|
||||
for (const row of options.diff.modified) {
|
||||
const assignments = row.changes
|
||||
.map(
|
||||
(change) =>
|
||||
`${quoteTableIdentifier(options.databaseType, change.column)} = ${formatGridSqlLiteral(change.source, options.databaseType)}`,
|
||||
)
|
||||
.join(", ");
|
||||
statements.push(
|
||||
`UPDATE ${table} SET ${assignments} WHERE ${whereByKey(row.keyValues, options.keyColumns, options.databaseType)};`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const row of options.diff.removed) {
|
||||
statements.push(
|
||||
`DELETE FROM ${table} WHERE ${whereByKey(row.keyValues, options.keyColumns, options.databaseType)};`,
|
||||
);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
export function generateDataSyncSql(options: GenerateDataSyncSqlOptions): string {
|
||||
return generateDataSyncStatements(options).join("\n");
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ColumnInfo, IndexInfo, DatabaseType } from "@/types/database";
|
||||
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, DatabaseType } from "@/types/database";
|
||||
|
||||
export interface ColumnDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
|
|
@ -9,17 +9,37 @@ export interface ColumnDiff {
|
|||
}
|
||||
|
||||
export interface IndexDiff {
|
||||
type: "added" | "removed";
|
||||
type: "added" | "removed" | "modified";
|
||||
name: string;
|
||||
source?: IndexInfo;
|
||||
target?: IndexInfo;
|
||||
changes?: string[];
|
||||
}
|
||||
|
||||
export interface ForeignKeyDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
name: string;
|
||||
source?: ForeignKeyInfo;
|
||||
target?: ForeignKeyInfo;
|
||||
changes?: string[];
|
||||
}
|
||||
|
||||
export interface TriggerDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
name: string;
|
||||
source?: TriggerInfo;
|
||||
target?: TriggerInfo;
|
||||
changes?: string[];
|
||||
}
|
||||
|
||||
export interface TableDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
objectType?: "table" | "view";
|
||||
name: string;
|
||||
columns?: ColumnDiff[];
|
||||
indexes?: IndexDiff[];
|
||||
foreignKeys?: ForeignKeyDiff[];
|
||||
triggers?: TriggerDiff[];
|
||||
ddl?: string;
|
||||
}
|
||||
|
||||
|
|
@ -65,8 +85,33 @@ export function diffIndexes(source: IndexInfo[], target: IndexInfo[]): IndexDiff
|
|||
|
||||
for (const si of source) {
|
||||
if (si.is_primary) continue;
|
||||
if (!targetMap.has(si.name)) {
|
||||
const ti = targetMap.get(si.name);
|
||||
if (!ti) {
|
||||
diffs.push({ type: "added", name: si.name, source: si });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
if (si.is_unique !== ti.is_unique) {
|
||||
changes.push(`unique: ${ti.is_unique ? "YES" : "NO"} → ${si.is_unique ? "YES" : "NO"}`);
|
||||
}
|
||||
if (si.columns.join(",") !== ti.columns.join(",")) {
|
||||
changes.push(`columns: ${ti.columns.join(", ")} → ${si.columns.join(", ")}`);
|
||||
}
|
||||
if ((si.index_type ?? "") !== (ti.index_type ?? "")) {
|
||||
changes.push(`type: ${ti.index_type ?? "default"} → ${si.index_type ?? "default"}`);
|
||||
}
|
||||
if ((si.filter ?? "") !== (ti.filter ?? "")) {
|
||||
changes.push(`filter: ${ti.filter ?? "none"} → ${si.filter ?? "none"}`);
|
||||
}
|
||||
if ((si.included_columns ?? []).join(",") !== (ti.included_columns ?? []).join(",")) {
|
||||
changes.push(
|
||||
`include: ${(ti.included_columns ?? []).join(", ") || "none"} → ${(si.included_columns ?? []).join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
diffs.push({ type: "modified", name: si.name, source: si, target: ti, changes });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +125,71 @@ export function diffIndexes(source: IndexInfo[], target: IndexInfo[]): IndexDiff
|
|||
return diffs;
|
||||
}
|
||||
|
||||
export function diffForeignKeys(source: ForeignKeyInfo[], target: ForeignKeyInfo[]): ForeignKeyDiff[] {
|
||||
const diffs: ForeignKeyDiff[] = [];
|
||||
const targetMap = new Map(target.map((fk) => [fk.name, fk]));
|
||||
const sourceMap = new Map(source.map((fk) => [fk.name, fk]));
|
||||
|
||||
for (const sfk of source) {
|
||||
const tfk = targetMap.get(sfk.name);
|
||||
if (!tfk) {
|
||||
diffs.push({ type: "added", name: sfk.name, source: sfk });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
if (sfk.column !== tfk.column) changes.push(`column: ${tfk.column} → ${sfk.column}`);
|
||||
if (sfk.ref_table !== tfk.ref_table) changes.push(`ref table: ${tfk.ref_table} → ${sfk.ref_table}`);
|
||||
if (sfk.ref_column !== tfk.ref_column) changes.push(`ref column: ${tfk.ref_column} → ${sfk.ref_column}`);
|
||||
|
||||
if (changes.length > 0) {
|
||||
diffs.push({ type: "modified", name: sfk.name, source: sfk, target: tfk, changes });
|
||||
}
|
||||
}
|
||||
|
||||
for (const tfk of target) {
|
||||
if (!sourceMap.has(tfk.name)) {
|
||||
diffs.push({ type: "removed", name: tfk.name, target: tfk });
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
export function diffTriggers(source: TriggerInfo[], target: TriggerInfo[]): TriggerDiff[] {
|
||||
const diffs: TriggerDiff[] = [];
|
||||
const targetMap = new Map(target.map((trigger) => [trigger.name, trigger]));
|
||||
const sourceMap = new Map(source.map((trigger) => [trigger.name, trigger]));
|
||||
|
||||
for (const sourceTrigger of source) {
|
||||
const targetTrigger = targetMap.get(sourceTrigger.name);
|
||||
if (!targetTrigger) {
|
||||
diffs.push({ type: "added", name: sourceTrigger.name, source: sourceTrigger });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
if (sourceTrigger.event !== targetTrigger.event) {
|
||||
changes.push(`event: ${targetTrigger.event} → ${sourceTrigger.event}`);
|
||||
}
|
||||
if (sourceTrigger.timing !== targetTrigger.timing) {
|
||||
changes.push(`timing: ${targetTrigger.timing} → ${sourceTrigger.timing}`);
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
diffs.push({ type: "modified", name: sourceTrigger.name, source: sourceTrigger, target: targetTrigger, changes });
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetTrigger of target) {
|
||||
if (!sourceMap.has(targetTrigger.name)) {
|
||||
diffs.push({ type: "removed", name: targetTrigger.name, target: targetTrigger });
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
export function diffTables(
|
||||
sourceTables: string[],
|
||||
targetTables: string[],
|
||||
|
|
@ -109,23 +219,79 @@ function columnDef(col: ColumnInfo, dbType: DatabaseType): string {
|
|||
return def;
|
||||
}
|
||||
|
||||
export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): string {
|
||||
function qualifiedName(name: string, dbType: DatabaseType, schema?: string): string {
|
||||
return schema ? `${quoteId(schema, dbType)}.${quoteId(name, dbType)}` : quoteId(name, dbType);
|
||||
}
|
||||
|
||||
function dropIndexSql(tableName: string, indexName: string, dbType: DatabaseType, schema?: string): string {
|
||||
const qt = qualifiedName(tableName, dbType, schema);
|
||||
const qi = qualifiedName(indexName, dbType, schema);
|
||||
if (dbType === "mysql" || dbType === "doris" || dbType === "starrocks") {
|
||||
return `DROP INDEX ${quoteId(indexName, dbType)} ON ${qt};`;
|
||||
}
|
||||
return `DROP INDEX IF EXISTS ${qi};`;
|
||||
}
|
||||
|
||||
function createIndexSql(tableName: string, idx: IndexInfo, dbType: DatabaseType, schema?: string): string {
|
||||
const qt = qualifiedName(tableName, dbType, schema);
|
||||
const cols = idx.columns.map((c) => quoteId(c, dbType)).join(", ");
|
||||
const unique = idx.is_unique ? "UNIQUE " : "";
|
||||
const idxType = idx.index_type ?? "";
|
||||
const usingClause = idxType && dbType === "postgres" ? ` USING ${idxType}` : "";
|
||||
const typePrefix = idxType && dbType === "sqlserver" ? `${idxType} ` : "";
|
||||
const incCols = idx.included_columns ?? [];
|
||||
const includeClause =
|
||||
incCols.length > 0 && (dbType === "postgres" || dbType === "sqlserver")
|
||||
? ` INCLUDE (${incCols.map((c) => quoteId(c, dbType)).join(", ")})`
|
||||
: "";
|
||||
const supportsWhere = dbType === "postgres" || dbType === "sqlserver" || dbType === "sqlite";
|
||||
const filter = idx.filter && supportsWhere ? ` WHERE ${idx.filter}` : "";
|
||||
return `CREATE ${unique}${typePrefix}INDEX ${quoteId(idx.name, dbType)} ON ${qt}${usingClause} (${cols})${includeClause}${filter};`;
|
||||
}
|
||||
|
||||
function dropForeignKeySql(tableName: string, fkName: string, dbType: DatabaseType, schema?: string): string {
|
||||
const qt = qualifiedName(tableName, dbType, schema);
|
||||
const qf = quoteId(fkName, dbType);
|
||||
if (dbType === "mysql" || dbType === "doris" || dbType === "starrocks") {
|
||||
return `ALTER TABLE ${qt} DROP FOREIGN KEY ${qf};`;
|
||||
}
|
||||
return `ALTER TABLE ${qt} DROP CONSTRAINT ${qf};`;
|
||||
}
|
||||
|
||||
function addForeignKeySql(tableName: string, fk: ForeignKeyInfo, dbType: DatabaseType, schema?: string): string {
|
||||
const qt = qualifiedName(tableName, dbType, schema);
|
||||
return `ALTER TABLE ${qt} ADD CONSTRAINT ${quoteId(fk.name, dbType)} FOREIGN KEY (${quoteId(fk.column, dbType)}) REFERENCES ${quoteId(fk.ref_table, dbType)} (${quoteId(fk.ref_column, dbType)});`;
|
||||
}
|
||||
|
||||
function dropObjectSql(diff: TableDiff, dbType: DatabaseType, schema?: string): string {
|
||||
const objectType = diff.objectType === "view" ? "VIEW" : "TABLE";
|
||||
return `DROP ${objectType} IF EXISTS ${qualifiedName(diff.name, dbType, schema)};`;
|
||||
}
|
||||
|
||||
export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType, schema?: string): string {
|
||||
const lines: string[] = [];
|
||||
const isMySQL = dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
|
||||
|
||||
for (const diff of diffs) {
|
||||
const qt = quoteId(diff.name, dbType);
|
||||
const qt = qualifiedName(diff.name, dbType, schema);
|
||||
|
||||
if (diff.type === "added" && diff.ddl) {
|
||||
lines.push(`-- Create table: ${diff.name}`);
|
||||
lines.push(`-- Create ${diff.objectType ?? "table"}: ${diff.name}`);
|
||||
lines.push(diff.ddl + ";");
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diff.type === "added" && diff.objectType === "view") {
|
||||
lines.push(`-- View exists only in source: ${diff.name}`);
|
||||
lines.push("-- Source view definition is not available from this driver yet.");
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diff.type === "removed") {
|
||||
lines.push(`-- Drop table: ${diff.name}`);
|
||||
lines.push(`DROP TABLE IF EXISTS ${qt};`);
|
||||
lines.push(`-- Drop ${diff.objectType ?? "table"}: ${diff.name}`);
|
||||
lines.push(dropObjectSql(diff, dbType, schema));
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
|
|
@ -133,6 +299,14 @@ export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): strin
|
|||
if (diff.type === "modified") {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (diff.foreignKeys) {
|
||||
for (const fk of diff.foreignKeys) {
|
||||
if (fk.type === "removed" || fk.type === "modified") {
|
||||
lines.push(dropForeignKeySql(diff.name, fk.name, dbType, schema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.columns) {
|
||||
for (const col of diff.columns) {
|
||||
if (col.type === "added" && col.source) {
|
||||
|
|
@ -164,34 +338,6 @@ export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): strin
|
|||
}
|
||||
}
|
||||
|
||||
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 " : "";
|
||||
const idxType = idx.source.index_type ?? "";
|
||||
const usingClause = idxType && dbType === "postgres" ? ` USING ${idxType}` : "";
|
||||
const typePrefix = idxType && dbType === "sqlserver" ? `${idxType} ` : "";
|
||||
const incCols = idx.source.included_columns ?? [];
|
||||
const includeClause =
|
||||
incCols.length > 0 && (dbType === "postgres" || dbType === "sqlserver")
|
||||
? ` INCLUDE (${incCols.map((c) => quoteId(c, dbType)).join(", ")})`
|
||||
: "";
|
||||
const supportsWhere = dbType === "postgres" || dbType === "sqlserver" || dbType === "sqlite";
|
||||
const filter = idx.source.filter && supportsWhere ? ` WHERE ${idx.source.filter}` : "";
|
||||
lines.push(
|
||||
`CREATE ${unique}${typePrefix}INDEX ${quoteId(idx.name, dbType)} ON ${qt}${usingClause} (${cols})${includeClause}${filter};`,
|
||||
);
|
||||
} 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) {
|
||||
|
|
@ -204,6 +350,52 @@ export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): strin
|
|||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (diff.indexes) {
|
||||
for (const idx of diff.indexes) {
|
||||
if (idx.type === "added" && idx.source) {
|
||||
lines.push(createIndexSql(diff.name, idx.source, dbType, schema));
|
||||
} else if (idx.type === "removed") {
|
||||
lines.push(dropIndexSql(diff.name, idx.name, dbType, schema));
|
||||
} else if (idx.type === "modified" && idx.source) {
|
||||
lines.push(dropIndexSql(diff.name, idx.name, dbType, schema));
|
||||
lines.push(createIndexSql(diff.name, idx.source, dbType, schema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.foreignKeys) {
|
||||
for (const fk of diff.foreignKeys) {
|
||||
if (fk.type === "added" && fk.source) {
|
||||
lines.push(addForeignKeySql(diff.name, fk.source, dbType, schema));
|
||||
} else if (fk.type === "modified" && fk.source) {
|
||||
lines.push(addForeignKeySql(diff.name, fk.source, dbType, schema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.triggers) {
|
||||
for (const trigger of diff.triggers) {
|
||||
lines.push(
|
||||
`-- Trigger ${trigger.type}: ${trigger.name} on ${diff.name}; review trigger definition manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.indexes || diff.foreignKeys || diff.triggers) {
|
||||
if (
|
||||
(diff.indexes?.length ?? 0) > 0 ||
|
||||
(diff.foreignKeys?.length ?? 0) > 0 ||
|
||||
(diff.triggers?.length ?? 0) > 0
|
||||
) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
if (dbType === "sqlite" && diff.foreignKeys?.length) {
|
||||
lines.push(`-- SQLite foreign key synchronization may require table rebuild for: ${diff.name}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ function isInColumnContext(beforeCursor: string): boolean {
|
|||
for (let i = lastWords.length - 1; i >= Math.max(0, lastWords.length - 3); i--) {
|
||||
const word = lastWords[i]?.toLowerCase().replace(/[^a-z0-9.]/g, "") ?? "";
|
||||
// Operators that indicate column context
|
||||
if (/^[=<>!\+\-\*\/(,]$/.test(word)) return true;
|
||||
if (/^[=<>!+\-*/(,]$/.test(word)) return true;
|
||||
// Keywords that directly precede column expressions
|
||||
if (["where", "on", "having", "set", "and", "or", "not", "is", "like", "in", "between", "select"].includes(word)) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -37,8 +37,12 @@ function crc32(data: Uint8Array): number {
|
|||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "")
|
||||
return [...value]
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code === 9 || code === 10 || code === 13 || code >= 32;
|
||||
})
|
||||
.join("")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
|
|
@ -66,7 +70,12 @@ function sheetRange(columnCount: number, rowCount: number): string {
|
|||
}
|
||||
|
||||
function normalizeSheetName(value?: string): string {
|
||||
const name = (value || "Sheet1").replace(/[\[\]:*?/\\]/g, " ").trim() || "Sheet1";
|
||||
const invalidChars = new Set(["[", "]", ":", "*", "?", "/", "\\"]);
|
||||
const name =
|
||||
[...(value || "Sheet1")]
|
||||
.map((char) => (invalidChars.has(char) ? " " : char))
|
||||
.join("")
|
||||
.trim() || "Sheet1";
|
||||
return name.slice(0, 31);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ 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);
|
||||
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
|
||||
const dataCompareSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
} | null>(null);
|
||||
const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null);
|
||||
const diagramSource = ref<{
|
||||
connectionId: string;
|
||||
|
|
@ -1154,6 +1160,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
applySidebarLayout,
|
||||
transferSource,
|
||||
schemaDiffSource,
|
||||
dataCompareSource,
|
||||
sqlFileSource,
|
||||
diagramSource,
|
||||
tableImportSource,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { compareDataRows, generateDataSyncSql } from "../src/lib/dataCompare.ts";
|
||||
|
||||
test("compares rows by primary key and reports added, removed, and modified rows", () => {
|
||||
const diff = compareDataRows({
|
||||
columns: ["id", "name", "active"],
|
||||
keyColumns: ["id"],
|
||||
sourceRows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Bob", false],
|
||||
[4, "Dora", true],
|
||||
],
|
||||
targetRows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Bobby", false],
|
||||
[3, "Cara", true],
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
diff.added.map((row) => row.keyValues),
|
||||
[{ id: 4 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
diff.removed.map((row) => row.keyValues),
|
||||
[{ id: 3 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
diff.modified.map((row) => row.changes),
|
||||
[[{ column: "name", source: "Bob", target: "Bobby" }]],
|
||||
);
|
||||
});
|
||||
|
||||
test("generates data synchronization SQL", () => {
|
||||
const diff = compareDataRows({
|
||||
columns: ["id", "name", "active"],
|
||||
keyColumns: ["id"],
|
||||
sourceRows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Bob", false],
|
||||
],
|
||||
targetRows: [
|
||||
[1, "Ada Lovelace", true],
|
||||
[3, "Cara", true],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
generateDataSyncSql({
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
columns: ["id", "name", "active"],
|
||||
keyColumns: ["id"],
|
||||
diff,
|
||||
databaseType: "postgres",
|
||||
}),
|
||||
[
|
||||
`INSERT INTO "public"."users" ("id", "name", "active") VALUES (2, 'Bob', FALSE);`,
|
||||
`UPDATE "public"."users" SET "name" = 'Ada' WHERE "id" = 1;`,
|
||||
`DELETE FROM "public"."users" WHERE "id" = 3;`,
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires at least one key column", () => {
|
||||
assert.throws(
|
||||
() => compareDataRows({ columns: ["id"], keyColumns: [], sourceRows: [[1]], targetRows: [[1]] }),
|
||||
/At least one key column/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate row keys", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
compareDataRows({
|
||||
columns: ["id", "name"],
|
||||
keyColumns: ["id"],
|
||||
sourceRows: [
|
||||
[1, "Ada"],
|
||||
[1, "Ada Clone"],
|
||||
],
|
||||
targetRows: [[1, "Ada"]],
|
||||
}),
|
||||
/Duplicate source key/,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { diffForeignKeys, diffIndexes, generateSyncSql, type TableDiff } from "../src/lib/schemaDiff.ts";
|
||||
import type { ForeignKeyInfo, IndexInfo } from "../src/types/database.ts";
|
||||
|
||||
function index(overrides: Partial<IndexInfo>): IndexInfo {
|
||||
return {
|
||||
name: "idx_users_email",
|
||||
columns: ["email"],
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function foreignKey(overrides: Partial<ForeignKeyInfo>): ForeignKeyInfo {
|
||||
return {
|
||||
name: "orders_user_id_fk",
|
||||
column: "user_id",
|
||||
ref_table: "users",
|
||||
ref_column: "id",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("detects modified indexes, not only added or removed indexes", () => {
|
||||
const diffs = diffIndexes(
|
||||
[index({ name: "idx_orders_status", columns: ["status", "created_at"], is_unique: false })],
|
||||
[index({ name: "idx_orders_status", columns: ["status"], is_unique: true })],
|
||||
);
|
||||
|
||||
assert.equal(diffs.length, 1);
|
||||
assert.equal(diffs[0].type, "modified");
|
||||
assert.deepEqual(diffs[0].changes, ["unique: YES → NO", "columns: status → status, created_at"]);
|
||||
});
|
||||
|
||||
test("detects foreign key additions, removals, and target changes", () => {
|
||||
const diffs = diffForeignKeys(
|
||||
[
|
||||
foreignKey({ name: "orders_user_id_fk" }),
|
||||
foreignKey({ name: "orders_account_id_fk", column: "account_id", ref_table: "accounts" }),
|
||||
],
|
||||
[
|
||||
foreignKey({ name: "orders_user_id_fk", ref_table: "members" }),
|
||||
foreignKey({ name: "orders_region_id_fk", column: "region_id", ref_table: "regions" }),
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => [diff.type, diff.name]),
|
||||
[
|
||||
["modified", "orders_user_id_fk"],
|
||||
["added", "orders_account_id_fk"],
|
||||
["removed", "orders_region_id_fk"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("generates sync SQL for index and foreign key changes", () => {
|
||||
const diffs: TableDiff[] = [
|
||||
{
|
||||
type: "modified",
|
||||
name: "orders",
|
||||
indexes: [
|
||||
{
|
||||
type: "modified",
|
||||
name: "idx_orders_status",
|
||||
source: index({ name: "idx_orders_status", columns: ["status", "created_at"], is_unique: true }),
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
type: "modified",
|
||||
name: "orders_user_id_fk",
|
||||
source: foreignKey({ name: "orders_user_id_fk", ref_table: "users" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
generateSyncSql(diffs, "postgres"),
|
||||
[
|
||||
'ALTER TABLE "orders" DROP CONSTRAINT "orders_user_id_fk";',
|
||||
'DROP INDEX IF EXISTS "idx_orders_status";',
|
||||
'CREATE UNIQUE INDEX "idx_orders_status" ON "orders" ("status", "created_at");',
|
||||
'ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id");',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("qualifies generated schema sync SQL with target schema", () => {
|
||||
const diffs: TableDiff[] = [
|
||||
{
|
||||
type: "modified",
|
||||
name: "orders",
|
||||
columns: [
|
||||
{
|
||||
type: "added",
|
||||
name: "status",
|
||||
source: {
|
||||
name: "status",
|
||||
data_type: "text",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{ type: "added", name: "idx_orders_status", source: index({ name: "idx_orders_status", columns: ["status"] }) },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
generateSyncSql(diffs, "postgres", "sales"),
|
||||
[
|
||||
"-- Alter table: orders",
|
||||
'ALTER TABLE "sales"."orders" ADD COLUMN "status" text;',
|
||||
"",
|
||||
'CREATE INDEX "idx_orders_status" ON "sales"."orders" ("status");',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue