feat(schema-diff): professional DDL diff view with deployment-aware colors and custom themes (#1112)

Co-authored-by: Sam <14344444@@qq.com>
This commit is contained in:
polemp 2026-06-12 17:14:05 +08:00 committed by GitHub
parent 2554ed95be
commit ef5ed05ae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 5695 additions and 1215 deletions

View File

@ -0,0 +1,297 @@
import { diffLines, type Change } from "diff";
export type HunkType = "equal" | "delete" | "insert" | "modify";
export interface DiffLine {
type: HunkType;
content: string;
lineNumber: number | null;
isPadding: boolean;
}
export interface DiffHunk {
id: string;
type: HunkType;
leftLines: DiffLine[];
rightLines: DiffLine[];
// Measured pixel positions after rendering
leftTop: number;
leftBottom: number;
rightTop: number;
rightBottom: number;
}
const SIMILARITY_THRESHOLD = 0.3;
const ALIGN_WINDOW = 3;
function splitLines(value: string): string[] {
const lines = value.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function buildLines(lines: string[], type: HunkType, startLineNum: number): DiffLine[] {
return lines.map((content, idx) => ({
type,
content,
lineNumber: startLineNum + idx,
isPadding: false,
}));
}
function buildPaddingLines(count: number): DiffLine[] {
return Array.from({ length: count }, () => ({
type: "equal" as HunkType,
content: "",
lineNumber: null,
isPadding: true,
}));
}
function normalizeDdl(ddl: string): string {
return ddl
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.replace(/[ \t]+$/g, ""))
.join("\n");
}
function collectSameKindChanges(changes: Change[], startIdx: number, kind: "added" | "removed"): [string[], number] {
const parts: string[] = [];
let i = startIdx;
while (i < changes.length && changes[i][kind]) {
parts.push(changes[i].value);
i++;
}
return [parts, i];
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
matrix[i][j] = b.charAt(i - 1) === a.charAt(j - 1) ? matrix[i - 1][j - 1] : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
return matrix[b.length][a.length];
}
function computeSimilarity(a: string, b: string): number {
if (a === b) return 1;
const longer = a.length > b.length ? a : b;
if (longer.length === 0) return 1;
const distance = levenshteinDistance(a, b);
return (longer.length - distance) / longer.length;
}
type AlignedItem = { type: "modify"; left: string; right: string } | { type: "delete"; left: string } | { type: "insert"; right: string };
function alignLineByLine(removedLines: string[], addedLines: string[]): AlignedItem[] {
const result: AlignedItem[] = [];
let r = 0;
let a = 0;
while (r < removedLines.length || a < addedLines.length) {
if (r < removedLines.length && a < addedLines.length) {
const directSim = computeSimilarity(removedLines[r], addedLines[a]);
if (directSim >= SIMILARITY_THRESHOLD) {
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
r++;
a++;
continue;
}
// Look ahead for best match within a small window
let bestR = -1;
let bestA = -1;
let bestSim = SIMILARITY_THRESHOLD;
for (let ri = r; ri < Math.min(r + ALIGN_WINDOW, removedLines.length); ri++) {
for (let aj = a; aj < Math.min(a + ALIGN_WINDOW, addedLines.length); aj++) {
const sim = computeSimilarity(removedLines[ri], addedLines[aj]);
if (sim > bestSim) {
bestSim = sim;
bestR = ri;
bestA = aj;
}
}
}
if (bestR >= 0 && bestA >= 0) {
while (r < bestR) {
result.push({ type: "delete", left: removedLines[r] });
r++;
}
while (a < bestA) {
result.push({ type: "insert", right: addedLines[a] });
a++;
}
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
r++;
a++;
} else {
result.push({ type: "delete", left: removedLines[r] });
result.push({ type: "insert", right: addedLines[a] });
r++;
a++;
}
} else if (r < removedLines.length) {
result.push({ type: "delete", left: removedLines[r] });
r++;
} else {
result.push({ type: "insert", right: addedLines[a] });
a++;
}
}
return result;
}
export function buildHunks(sourceDdl: string, targetDdl: string): DiffHunk[] {
const normalizedSource = normalizeDdl(sourceDdl);
const normalizedTarget = normalizeDdl(targetDdl);
const changes = diffLines(normalizedSource, normalizedTarget, { newlineIsToken: false });
const hunks: DiffHunk[] = [];
let leftLineNum = 1;
let rightLineNum = 1;
let hunkIdCounter = 0;
function nextId(): string {
return `hunk-${hunkIdCounter++}`;
}
let i = 0;
while (i < changes.length) {
const change = changes[i];
if (!change.added && !change.removed) {
const lines = splitLines(change.value);
hunks.push({
id: nextId(),
type: "equal",
leftLines: buildLines(lines, "equal", leftLineNum),
rightLines: buildLines(lines, "equal", rightLineNum),
leftTop: 0,
leftBottom: 0,
rightTop: 0,
rightBottom: 0,
});
leftLineNum += lines.length;
rightLineNum += lines.length;
i++;
continue;
}
if (change.removed) {
const [removedParts, afterRemoved] = collectSameKindChanges(changes, i, "removed");
const [addedParts, afterAdded] = collectSameKindChanges(changes, afterRemoved, "added");
const removedValue = removedParts.join("");
const removedLines = splitLines(removedValue);
if (addedParts.length > 0) {
const addedValue = addedParts.join("");
const addedLines = splitLines(addedValue);
const aligned = alignLineByLine(removedLines, addedLines);
for (const item of aligned) {
if (item.type === "modify") {
const maxLines = 1;
const leftReal = buildLines([item.left], "modify", leftLineNum);
const rightReal = buildLines([item.right], "modify", rightLineNum);
leftLineNum++;
rightLineNum++;
hunks.push({
id: nextId(),
type: "modify",
leftLines: padLines(leftReal, maxLines, "modify"),
rightLines: padLines(rightReal, maxLines, "modify"),
leftTop: 0,
leftBottom: 0,
rightTop: 0,
rightBottom: 0,
});
} else if (item.type === "delete") {
const leftReal = buildLines([item.left], "delete", leftLineNum);
leftLineNum++;
hunks.push({
id: nextId(),
type: "delete",
leftLines: leftReal,
rightLines: buildPaddingLines(1),
leftTop: 0,
leftBottom: 0,
rightTop: 0,
rightBottom: 0,
});
} else if (item.type === "insert") {
const rightReal = buildLines([item.right], "insert", rightLineNum);
rightLineNum++;
hunks.push({
id: nextId(),
type: "insert",
leftLines: buildPaddingLines(1),
rightLines: rightReal,
leftTop: 0,
leftBottom: 0,
rightTop: 0,
rightBottom: 0,
});
}
}
} else {
const maxLines = removedLines.length;
const leftReal = buildLines(removedLines, "delete", leftLineNum);
leftLineNum += removedLines.length;
hunks.push({
id: nextId(),
type: "delete",
leftLines: leftReal,
rightLines: buildPaddingLines(maxLines),
leftTop: 0,
leftBottom: 0,
rightTop: 0,
rightBottom: 0,
});
}
i = afterAdded;
continue;
}
if (change.added) {
const [addedParts, afterAdded] = collectSameKindChanges(changes, i, "added");
const addedValue = addedParts.join("");
const addedLines = splitLines(addedValue);
const rightReal = buildLines(addedLines, "insert", rightLineNum);
rightLineNum += addedLines.length;
hunks.push({
id: nextId(),
type: "insert",
leftLines: buildPaddingLines(addedLines.length),
rightLines: rightReal,
leftTop: 0,
leftBottom: 0,
rightTop: 0,
rightBottom: 0,
});
i = afterAdded;
continue;
}
}
return hunks;
}
function padLines(lines: DiffLine[], targetCount: number, type: HunkType): DiffLine[] {
if (lines.length >= targetCount) return lines;
const padding = Array.from({ length: targetCount - lines.length }, () => ({
type,
content: "",
lineNumber: null,
isPadding: true,
}));
return [...lines, ...padding];
}

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DiffHunk } from "@/components/diff/DiffHunkBuilder";
const props = defineProps<{
hunks: DiffHunk[];
containerWidth: number;
containerHeight: number;
}>();
const connectionPaths = computed(() => {
const paths: { d: string; stroke: string; id: string }[] = [];
const midX = props.containerWidth / 2;
const halfLine = 10;
for (const hunk of props.hunks) {
// Only draw short connectors for modify hunks
if (hunk.type !== "modify") continue;
if (hunk.leftBottom <= hunk.leftTop || hunk.rightBottom <= hunk.rightTop) continue;
if (hunk.leftBottom < 0 || hunk.rightBottom < 0) continue;
if (hunk.leftTop > props.containerHeight || hunk.rightTop > props.containerHeight) continue;
const leftY = (hunk.leftTop + hunk.leftBottom) / 2;
const rightY = (hunk.rightTop + hunk.rightBottom) / 2;
paths.push({
id: hunk.id,
d: `M ${midX - halfLine},${leftY} L ${midX + halfLine},${rightY}`,
stroke: "rgba(234, 179, 8, 0.6)",
});
}
return paths;
});
</script>
<template>
<svg class="absolute inset-0 pointer-events-none z-10" :width="containerWidth" :height="containerHeight" xmlns="http://www.w3.org/2000/svg">
<path v-for="path in connectionPaths" :key="path.id" :d="path.d" :stroke="path.stroke" fill="none" stroke-width="2" />
</svg>
</template>

View File

@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Download, Upload, Plus, Trash2, Copy, FileDown } from "@lucide/vue";
import type { SchemaDiffConfig } from "@/types/schemaDiff";
const props = defineProps<{
configs: SchemaDiffConfig[];
activeConfigId: string;
}>();
const emit = defineEmits<{
(e: "update:activeConfigId", id: string): void;
(e: "create", name: string): void;
(e: "rename", id: string, name: string): void;
(e: "delete", id: string): void;
(e: "duplicate", id: string): void;
(e: "export", config: SchemaDiffConfig): void;
(e: "exportAll", configs: SchemaDiffConfig[]): void;
(e: "import", jsonText: string): void;
}>();
const { t } = useI18n();
const renameDialogOpen = ref(false);
const renameValue = ref("");
const renamingId = ref("");
const importDialogOpen = ref(false);
const importValue = ref("");
const importError = ref("");
function onSelectChange(value: unknown) {
emit("update:activeConfigId", String(value));
}
function startRename(config: SchemaDiffConfig) {
renamingId.value = config.id;
renameValue.value = config.name;
renameDialogOpen.value = true;
}
function confirmRename() {
if (renamingId.value && renameValue.value.trim()) {
emit("rename", renamingId.value, renameValue.value.trim());
}
renameDialogOpen.value = false;
}
function handleCreate() {
const baseName = t("schemaDiff.newConfigName");
let name = baseName;
let counter = 1;
while (props.configs.some((c) => c.name === name)) {
counter++;
name = `${baseName} ${counter}`;
}
emit("create", name);
}
function handleImport() {
importError.value = "";
try {
JSON.parse(importValue.value);
emit("import", importValue.value);
importDialogOpen.value = false;
importValue.value = "";
} catch {
importError.value = t("schemaDiff.importInvalidJson");
}
}
function downloadJson(data: string, filename: string) {
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function onExport() {
const config = props.configs.find((c) => c.id === props.activeConfigId);
if (config) {
emit("export", config);
downloadJson(JSON.stringify(config, null, 2), `dbx-schema-diff-${config.name}.json`);
}
}
function onExportAll() {
emit("exportAll", props.configs);
downloadJson(JSON.stringify(props.configs, null, 2), "dbx-schema-diff-configs.json");
}
async function onImportFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
JSON.parse(text);
emit("import", text);
importDialogOpen.value = false;
} catch {
importError.value = t("schemaDiff.importInvalidJson");
}
input.value = "";
}
const activeConfig = computed(() => props.configs.find((c) => c.id === props.activeConfigId));
</script>
<template>
<div class="flex items-center gap-2">
<Select :model-value="activeConfigId" @update:model-value="onSelectChange">
<SelectTrigger class="h-8 text-sm min-w-[180px]">
<SelectValue :placeholder="t('schemaDiff.selectConfig')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="config in configs" :key="config.id" :value="config.id">
{{ config.name }}
</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.newConfig')" @click="handleCreate">
<Plus class="h-4 w-4" />
</Button>
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.renameConfig')" @click="startRename(activeConfig)">
<Copy class="h-4 w-4" />
</Button>
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.duplicateConfig')" @click="emit('duplicate', activeConfig.id)">
<FileDown class="h-4 w-4" />
</Button>
<Button v-if="activeConfig && configs.length > 1" variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.deleteConfig')" @click="activeConfig && emit('delete', activeConfig.id)">
<Trash2 class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.exportConfig')" @click="onExport">
<Download class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.importConfig')" @click="importDialogOpen = true">
<Upload class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" class="h-8 w-8" :title="t('schemaDiff.exportAllConfigs')" @click="onExportAll">
<FileDown class="h-4 w-4" />
</Button>
<!-- Rename Dialog -->
<Dialog v-model:open="renameDialogOpen">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("schemaDiff.renameConfig") }}</DialogTitle>
</DialogHeader>
<div class="py-4">
<Label class="text-sm">{{ t("schemaDiff.configName") }}</Label>
<Input v-model="renameValue" class="mt-2" @keydown.enter="confirmRename" />
</div>
<DialogFooter>
<Button variant="outline" @click="renameDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button @click="confirmRename">{{ t("common.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Import Dialog -->
<Dialog v-model:open="importDialogOpen">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ t("schemaDiff.importConfig") }}</DialogTitle>
</DialogHeader>
<div class="py-4 space-y-4">
<div>
<Label class="text-sm">{{ t("schemaDiff.importFromFile") }}</Label>
<Input type="file" accept=".json" class="mt-2" @change="onImportFile" />
</div>
<div>
<Label class="text-sm">{{ t("schemaDiff.importFromText") }}</Label>
<textarea v-model="importValue" class="mt-2 w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm" :placeholder="t('schemaDiff.importPlaceholder')" />
</div>
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="importDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button @click="handleImport">{{ t("common.import") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>

View File

@ -0,0 +1,457 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
const { t } = useI18n();
const store = useConnectionStore();
const props = defineProps<{
configs: SchemaDiffConfig[];
activeConfigId: string;
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
ignoreComments: boolean;
options: SchemaDiffCompareOptions;
loading: boolean;
recentConfigs: SchemaDiffConfig[];
}>();
const emit = defineEmits<{
(e: "update:sourceConnectionId", value: string): void;
(e: "update:sourceDatabase", value: string): void;
(e: "update:sourceSchema", value: string): void;
(e: "update:targetConnectionId", value: string): void;
(e: "update:targetDatabase", value: string): void;
(e: "update:targetSchema", value: string): void;
(e: "update:ignoreComments", value: boolean): void;
(e: "compare"): void;
(e: "saveConfig"): void;
(e: "loadConfig"): void;
(e: "showOptions"): void;
(e: "swap"): void;
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
(e: "deleteHistoryConfig", configId: string): void;
}>();
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
const targetDatabases = ref<string[]>([]);
const targetSchemas = ref<string[]>([]);
const sourceDbVersion = ref<string | null>(null);
const targetDbVersion = ref<string | null>(null);
const sqlConnections = computed(() => store.connections.filter((c: any) => c.db_type !== "mongodb" && c.db_type !== "redis"));
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
const canCompare = computed(() => {
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
});
async function loadDatabases(connectionId: string, side: "source" | "target") {
if (!connectionId) return;
try {
await store.ensureConnected(connectionId);
const dbs = await api.listDatabases(connectionId);
const dbNames = Array.isArray(dbs) ? dbs.map((db: any) => (typeof db === "string" ? db : db.name || db.database)) : [];
if (side === "source") {
sourceDatabases.value = dbNames;
if (props.sourceDatabase) {
await fetchDbVersion(connectionId, props.sourceDatabase, props.sourceSchema, "source");
// Ensure schema list is loaded after databases are available (handles race with sourceDatabase watcher)
if (isSchemaAware(sourceConfig.value?.db_type)) {
await loadSchemas("source");
}
}
} else {
targetDatabases.value = dbNames;
if (props.targetDatabase) {
await fetchDbVersion(connectionId, props.targetDatabase, props.targetSchema, "target");
// Ensure schema list is loaded after databases are available (handles race with targetDatabase watcher)
if (isSchemaAware(targetConfig.value?.db_type)) {
await loadSchemas("target");
}
}
}
} catch {
if (side === "source") {
sourceDatabases.value = [];
sourceDbVersion.value = null;
} else {
targetDatabases.value = [];
targetDbVersion.value = null;
}
}
}
async function loadSchemas(side: "source" | "target") {
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
if (!connectionId || !database) return;
try {
await store.ensureConnected(connectionId);
const schemas = await api.listSchemas(connectionId, database);
if (side === "source") {
sourceSchemas.value = schemas;
} else {
targetSchemas.value = schemas;
}
await fetchDbVersion(connectionId, database, schema, side);
} catch {
if (side === "source") {
sourceSchemas.value = [];
} else {
targetSchemas.value = [];
}
}
}
watch(
() => props.sourceConnectionId,
async (id) => {
if (id) {
await loadDatabases(id, "source");
} else {
sourceDatabases.value = [];
}
},
{ immediate: true },
);
watch(
() => props.sourceDatabase,
async (db) => {
if (db && props.sourceConnectionId) {
await loadSchemas("source");
} else {
sourceSchemas.value = [];
}
},
{ immediate: true },
);
watch(
() => props.targetConnectionId,
async (id) => {
if (id) {
await loadDatabases(id, "target");
} else {
targetDatabases.value = [];
}
},
{ immediate: true },
);
watch(
() => props.targetDatabase,
async (db) => {
if (db && props.targetConnectionId) {
await loadSchemas("target");
} else {
targetSchemas.value = [];
}
},
{ immediate: true },
);
function connectionIconType(connectionId: string) {
const c = store.getConfig(connectionId);
return c?.driver_profile || c?.db_type || "mysql";
}
function getConnectionInfo(connectionId: string) {
const c = store.getConfig(connectionId);
if (!c) return null;
return {
name: c.name,
dbType: c.db_type,
host: c.host,
port: c.port,
};
}
async function fetchDbVersion(connectionId: string, database: string, schema: string, side: "source" | "target") {
try {
await store.ensureConnected(connectionId);
const config = store.getConfig(connectionId);
const dbType = config?.db_type;
let sql = "";
switch (dbType) {
case "postgres":
case "opengauss":
sql = "SELECT version()";
break;
case "mysql":
sql = "SELECT VERSION()";
break;
case "sqlite":
sql = "SELECT sqlite_version()";
break;
default:
return;
}
const result = await api.executeQuery(connectionId, database, sql, schema || undefined);
if (result.rows && result.rows.length > 0) {
const version = String(result.rows[0][0]);
if (side === "source") {
sourceDbVersion.value = version;
} else {
targetDbVersion.value = version;
}
}
} catch (e) {
console.error(`[fetchDbVersion] Failed to fetch version for ${side}:`, e);
if (side === "source") {
sourceDbVersion.value = null;
} else {
targetDbVersion.value = null;
}
}
}
</script>
<template>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-center gap-4 py-2">
<div class="text-center">
<div class="text-xs text-muted-foreground">{{ sourceConfig?.name || t("diff.source") }}</div>
<div class="text-xs font-medium">{{ sourceDatabase }}{{ sourceSchema ? `.${sourceSchema}` : "" }}</div>
</div>
<div class="flex items-center gap-2">
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-5 h-5" />
<ArrowLeftRight class="w-4 h-4 text-muted-foreground" />
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-5 h-5" />
</div>
<div class="text-center">
<div class="text-xs text-muted-foreground">{{ targetConfig?.name || t("diff.target") }}</div>
<div class="text-xs font-medium">{{ targetDatabase }}{{ targetSchema ? `.${targetSchema}` : "" }}</div>
</div>
</div>
<!-- Source / Target Selection -->
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
<!-- Source Side -->
<div class="space-y-3">
<div class="text-sm font-medium text-blue-500">{{ t("diff.source") }}</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("diff.connection") }}</Label>
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => $emit('update:sourceConnectionId', String(v))">
<SelectTrigger class="h-8 text-xs">
<div class="flex items-center gap-2">
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-3.5 h-3.5" />
<SelectValue :placeholder="t('diff.selectConnection')" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex items-center gap-2">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
{{ c.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("diff.database") }}</Label>
<Select :model-value="sourceDatabase" @update:model-value="(v: any) => $emit('update:sourceDatabase', String(v))">
<SelectTrigger class="h-8 text-xs" :disabled="!sourceDatabases.length">
<SelectValue :placeholder="t('diff.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="isSchemaAware(sourceConfig?.db_type)" class="space-y-1.5">
<Label class="text-xs">{{ t("diff.schema") }}</Label>
<Select :model-value="sourceSchema" @update:model-value="(v: any) => $emit('update:sourceSchema', String(v))">
<SelectTrigger class="h-8 text-xs" :disabled="!sourceSchemas.length">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Source Info -->
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
<span>{{ getConnectionInfo(sourceConnectionId)?.dbType }}</span>
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
<span>{{ getConnectionInfo(sourceConnectionId)?.name }}</span>
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
<span>{{ getConnectionInfo(sourceConnectionId)?.host }}</span>
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
<span>{{ getConnectionInfo(sourceConnectionId)?.port }}</span>
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
<span>{{ sourceDbVersion || "--" }}</span>
</div>
</div>
</div>
<!-- Swap Button -->
<div class="pt-8">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('swap')">
<ArrowLeftRight class="w-4 h-4" />
</Button>
</div>
<!-- Target Side -->
<div class="space-y-3">
<div class="text-sm font-medium text-green-500">{{ t("diff.target") }}</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("diff.connection") }}</Label>
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => $emit('update:targetConnectionId', String(v))">
<SelectTrigger class="h-8 text-xs">
<div class="flex items-center gap-2">
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-3.5 h-3.5" />
<SelectValue :placeholder="t('diff.selectConnection')" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex items-center gap-2">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
{{ c.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("diff.database") }}</Label>
<Select :model-value="targetDatabase" @update:model-value="(v: any) => $emit('update:targetDatabase', String(v))">
<SelectTrigger class="h-8 text-xs" :disabled="!targetDatabases.length">
<SelectValue :placeholder="t('diff.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="isSchemaAware(targetConfig?.db_type)" class="space-y-1.5">
<Label class="text-xs">{{ t("diff.schema") }}</Label>
<Select :model-value="targetSchema" @update:model-value="(v: any) => $emit('update:targetSchema', String(v))">
<SelectTrigger class="h-8 text-xs" :disabled="!targetSchemas.length">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Target Info -->
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
<span>{{ getConnectionInfo(targetConnectionId)?.dbType }}</span>
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
<span>{{ getConnectionInfo(targetConnectionId)?.name }}</span>
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
<span>{{ getConnectionInfo(targetConnectionId)?.host }}</span>
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
<span>{{ getConnectionInfo(targetConnectionId)?.port }}</span>
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
<span>{{ targetDbVersion || "--" }}</span>
</div>
</div>
</div>
</div>
<!-- Options -->
<div class="flex items-center gap-2 p-3 rounded-lg border bg-muted/20">
<input id="schema-diff-ignore-comments" :checked="ignoreComments" type="checkbox" class="accent-primary" @change="$emit('update:ignoreComments', ($event.target as HTMLInputElement).checked)" />
<Label for="schema-diff-ignore-comments" class="cursor-pointer text-xs">
{{ t("diff.ignoreComments") }}
</Label>
</div>
<!-- Recent Configs Dropdown -->
<div v-if="recentConfigs.length > 0" class="flex items-center gap-2">
<Label class="text-xs text-muted-foreground">{{ t("diff.recentConfigs") }}</Label>
<Select
:model-value="''"
@update:model-value="
(v: any) => {
const config = recentConfigs.find((c) => c.id === v);
if (config) $emit('loadHistoryConfig', config);
}
"
>
<SelectTrigger class="h-8 text-xs w-[280px]">
<SelectValue :placeholder="t('diff.selectRecentConfig')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="config in recentConfigs" :key="config.id" :value="config.id" class="pr-8">
<div class="flex items-center justify-between w-full gap-2">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs font-medium truncate">{{ config.name }}</span>
<span class="text-[10px] text-muted-foreground truncate">
{{ store.getConfig(config.sourceConnectionId)?.name || config.sourceConnectionId }}
/{{ config.sourceDatabase }}{{ config.sourceSchema ? `.${config.sourceSchema}` : "" }}
{{ store.getConfig(config.targetConnectionId)?.name || config.targetConnectionId }}
/{{ config.targetDatabase }}{{ config.targetSchema ? `.${config.targetSchema}` : "" }}
</span>
</div>
<button class="shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors" @click.stop="$emit('deleteHistoryConfig', config.id)">
<X class="w-3 h-3" />
</button>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Bottom Actions -->
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="$emit('saveConfig')">
<Save class="w-3.5 h-3.5 mr-1" />
{{ t("diff.saveConfig") }}
</Button>
<Button variant="outline" size="sm" @click="$emit('loadConfig')">
<FolderOpen class="w-3.5 h-3.5 mr-1" />
{{ t("diff.loadConfig") }}
</Button>
<Button variant="outline" size="sm" @click="$emit('showOptions')">
<Settings class="w-3.5 h-3.5 mr-1" />
{{ t("diff.options") }}
</Button>
</div>
<Button size="sm" :disabled="!canCompare || loading" @click="$emit('compare')">
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
{{ loading ? t("common.loading") : t("diff.compare") }}
</Button>
</div>
</div>
</template>

View File

@ -0,0 +1,390 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/clipboard";
import { useToast } from "@/composables/useToast";
import { useSettingsStore } from "@/stores/settingsStore";
import { DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
import { useDiffScrollSync } from "@/composables/useDiffScrollSync";
import { buildHunks, type DiffLine } from "@/components/diff/DiffHunkBuilder";
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
import { FileCode, ScrollText, Copy, Play } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import type { SchemaDiffObject } from "@/lib/schemaDiff";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const ddlColors = computed(() => {
const themes = settingsStore.editorSettings.customThemes;
const activeId = settingsStore.editorSettings.activeCustomThemeId;
const activeTheme = themes.find((t) => t.id === activeId);
return activeTheme?.ddlColors ?? DEFAULT_CUSTOM_THEME_DDL_COLORS;
});
function toRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha / 100})`;
}
const props = defineProps<{
selectedObject: SchemaDiffObject | null;
deploySql: string;
deploySqlAll: string;
}>();
const emit = defineEmits<{
executeScript: [];
}>();
const activeTab = ref<"ddl" | "script" | "scriptAll">("ddl");
const diffContainerRef = ref<HTMLDivElement>();
const leftPaneRef = ref<HTMLDivElement>();
const rightPaneRef = ref<HTMLDivElement>();
const containerSize = ref({ width: 0, height: 0 });
const connectorKey = ref(0);
const hunks = computed(() => {
if (!props.selectedObject?.sourceDdl && !props.selectedObject?.targetDdl) return [];
return buildHunks(props.selectedObject?.sourceDdl || "", props.selectedObject?.targetDdl || "");
});
const { syncScroll, measureHunks } = useDiffScrollSync({
container: diffContainerRef,
leftPane: leftPaneRef,
rightPane: rightPaneRef,
hunks,
});
// Cache char-level diff segments so we don't recompute on every render
const modifySegments = computed(() => {
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
for (const hunk of hunks.value) {
if (hunk.type !== "modify") continue;
for (let i = 0; i < hunk.leftLines.length; i++) {
const left = hunk.leftLines[i];
const right = hunk.rightLines[i];
if (left.isPadding || right.isPadding) continue;
const key = `${hunk.id}:${i}`;
map.set(key, renderModifyLine(left, right));
}
}
return map;
});
let measureRaf: number | null = null;
let measureTimeout: ReturnType<typeof setTimeout> | null = null;
function requestMeasure() {
if (measureRaf) return;
measureRaf = requestAnimationFrame(() => {
measureRaf = null;
measureHunks();
connectorKey.value++;
});
}
function requestMeasureDebounced() {
if (measureTimeout) clearTimeout(measureTimeout);
measureTimeout = setTimeout(() => {
requestMeasure();
}, 100);
}
function handleScroll(from: "left" | "right") {
syncScroll(from);
requestMeasureDebounced();
}
watch(
() => props.selectedObject?.id,
async () => {
await nextTick();
updateContainerSize();
requestMeasure();
},
);
function updateContainerSize() {
const el = diffContainerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
containerSize.value = { width: rect.width, height: rect.height };
}
function onSplitpanesResized() {
updateContainerSize();
requestMeasure();
}
function lineBackground(line: DiffLine): string | undefined {
if (line.isPadding) return undefined;
if (line.type === "delete") {
// source-only = will be added to target = green
return toRgba(ddlColors.value.addedRowBg, ddlColors.value.addedRowBgAlpha);
}
if (line.type === "insert") {
// target-only = will be removed from target = red
return toRgba(ddlColors.value.removedRowBg, ddlColors.value.removedRowBgAlpha);
}
if (line.type === "modify") {
return toRgba(ddlColors.value.modifiedRowBg, ddlColors.value.modifiedRowBgAlpha);
}
return undefined;
}
function lineTextClass(line: DiffLine): string {
if (line.isPadding) return "text-transparent";
if (line.type === "insert") return "line-through opacity-80";
return "";
}
function computeCharDiffs(source: string, target: string): { source: string; target: string }[] {
const result: { source: string; target: string }[] = [];
let sIdx = 0;
let tIdx = 0;
while (sIdx < source.length || tIdx < target.length) {
if (sIdx >= source.length) {
result.push({ source: "", target: target.substring(tIdx) });
break;
}
if (tIdx >= target.length) {
result.push({ source: source.substring(sIdx), target: "" });
break;
}
if (source[sIdx] === target[tIdx]) {
let matchLen = 0;
while (sIdx + matchLen < source.length && tIdx + matchLen < target.length && source[sIdx + matchLen] === target[tIdx + matchLen]) {
matchLen++;
}
result.push({
source: source.substring(sIdx, sIdx + matchLen),
target: target.substring(tIdx, tIdx + matchLen),
});
sIdx += matchLen;
tIdx += matchLen;
} else {
let sMatch = -1;
let tMatch = -1;
for (let i = 0; i < Math.min(10, source.length - sIdx, target.length - tIdx); i++) {
if (source[sIdx + i] === target[tIdx]) {
sMatch = i;
tMatch = 0;
break;
}
if (source[sIdx] === target[tIdx + i]) {
sMatch = 0;
tMatch = i;
break;
}
}
if (sMatch === -1) {
sMatch = Math.min(1, source.length - sIdx);
tMatch = Math.min(1, target.length - tIdx);
}
result.push({
source: source.substring(sIdx, sIdx + (sMatch > 0 ? sMatch : 1)),
target: target.substring(tIdx, tIdx + (tMatch > 0 ? tMatch : 1)),
});
sIdx += sMatch > 0 ? sMatch : 1;
tIdx += tMatch > 0 ? tMatch : 1;
}
}
return result;
}
function renderModifyLine(leftLine: DiffLine, rightLine: DiffLine): { leftSegments: Segment[]; rightSegments: Segment[] } {
const charDiffs = computeCharDiffs(leftLine.content, rightLine.content);
const leftSegments: Segment[] = [];
const rightSegments: Segment[] = [];
for (const cd of charDiffs) {
if (cd.source === cd.target) {
leftSegments.push({ text: cd.source, changed: false });
rightSegments.push({ text: cd.target, changed: false });
} else {
if (cd.source) leftSegments.push({ text: cd.source, changed: true });
if (cd.target) rightSegments.push({ text: cd.target, changed: true });
}
}
return { leftSegments, rightSegments };
}
interface Segment {
text: string;
changed: boolean;
}
function copyDeploySql() {
copyToClipboard(props.deploySql);
toast(t("diff.copied"), 2000);
}
function copyDeploySqlAll() {
copyToClipboard(props.deploySqlAll);
toast(t("diff.copied"), 2000);
}
</script>
<template>
<div class="border rounded-md flex flex-col h-full">
<!-- Tabs -->
<div class="flex border-b shrink-0">
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'ddl' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'ddl'">
<FileCode class="w-3.5 h-3.5" />
{{ t("diff.ddlCompare") }}
</button>
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'script' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'script'">
<ScrollText class="w-3.5 h-3.5" />
{{ t("diff.deployScript") }}
</button>
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'scriptAll' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'scriptAll'">
<ScrollText class="w-3.5 h-3.5" />
{{ t("diff.deployScriptAll") }}
</button>
</div>
<!-- DDL Compare -->
<div v-if="activeTab === 'ddl'" class="flex-1 overflow-hidden relative">
<!-- No object selected -->
<div v-if="!selectedObject" class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{{ t("diff.selectObjectToCompare") }}
</div>
<!-- No DDL data available -->
<div v-else-if="!selectedObject.sourceDdl && !selectedObject.targetDdl" class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{{ t("diff.noDdlAvailable") }}
</div>
<!-- Diff View -->
<div v-else ref="diffContainerRef" class="absolute inset-0 font-mono text-xs leading-relaxed">
<Splitpanes class="h-full" @resized="onSplitpanesResized">
<!-- Source DDL -->
<Pane min-size="20">
<div ref="leftPaneRef" class="h-full overflow-y-auto border-r" @scroll="handleScroll('left')">
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
{{ t("diff.sourceDdl") }}
</div>
<div v-for="hunk in hunks" :key="`left-${hunk.id}`" :data-hunk-id="hunk.id">
<div
v-for="(line, idx) in hunk.leftLines"
:key="`l-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.leftLines.length - 1,
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
<span class="flex-1 px-1 whitespace-pre" :class="lineTextClass(line)">
<template v-if="line.type === 'modify' && !line.isPadding">
<template v-for="(segment, si) in modifySegments.get(`${hunk.id}:${idx}`)?.leftSegments ?? []" :key="`ls-${si}`">
<span :style="segment.changed ? { backgroundColor: toRgba(ddlColors.modifiedCharBg, ddlColors.modifiedCharBgAlpha) } : undefined">{{ segment.text }}</span>
</template>
</template>
<span v-else>{{ line.isPadding ? "\u00A0" : line.content }}</span>
</span>
</div>
</div>
</div>
</Pane>
<!-- Target DDL -->
<Pane min-size="20">
<div ref="rightPaneRef" class="h-full overflow-y-auto" @scroll="handleScroll('right')">
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
{{ t("diff.targetDdl") }}
</div>
<div v-for="hunk in hunks" :key="`right-${hunk.id}`" :data-hunk-id="hunk.id">
<div
v-for="(line, idx) in hunk.rightLines"
:key="`r-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.rightLines.length - 1,
}"
:style="{ backgroundColor: lineBackground(line) }"
>
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
{{ line.lineNumber ?? "" }}
</span>
<span class="flex-1 px-1 whitespace-pre" :class="lineTextClass(line)">
<template v-if="line.type === 'modify' && !line.isPadding">
<template v-for="(segment, si) in modifySegments.get(`${hunk.id}:${idx}`)?.rightSegments ?? []" :key="`rs-${si}`">
<span :style="segment.changed ? { backgroundColor: toRgba(ddlColors.modifiedCharBg, ddlColors.modifiedCharBgAlpha) } : undefined">{{ segment.text }}</span>
</template>
</template>
<span v-else>{{ line.isPadding ? "\u00A0" : line.content }}</span>
</span>
</div>
</div>
</div>
</Pane>
</Splitpanes>
<!-- SVG Connector Overlay -->
<DiffSvgConnector :key="connectorKey" :hunks="hunks" :container-width="containerSize.width" :container-height="containerSize.height" />
</div>
</div>
<!-- Deploy Script -->
<div v-else-if="activeTab === 'script'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ t("diff.deployScriptDesc") }}</span>
<div class="flex gap-1">
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="copyDeploySql">
<Copy class="w-3 h-3" />
{{ t("diff.copy") }}
</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="$emit('executeScript')">
<Play class="w-3 h-3" />
{{ t("diff.execute") }}
</Button>
</div>
</div>
<div class="flex-1 overflow-auto p-3">
<pre class="text-xs whitespace-pre-wrap font-mono">{{ deploySql || t("diff.noDeployScript") }}</pre>
</div>
</div>
<!-- Deploy Script All -->
<div v-else-if="activeTab === 'scriptAll'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ t("diff.deployScriptAllDesc") }}</span>
<div class="flex gap-1">
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="copyDeploySqlAll">
<Copy class="w-3 h-3" />
{{ t("diff.copy") }}
</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="$emit('executeScript')">
<Play class="w-3 h-3" />
{{ t("diff.executeAll") }}
</Button>
</div>
</div>
<div class="flex-1 overflow-auto p-3">
<pre class="text-xs whitespace-pre-wrap font-mono">{{ deploySqlAll || t("diff.noDeployScriptAll") }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.splitpanes--vertical > .splitpanes__splitter) {
background-color: hsl(var(--border));
width: 4px;
cursor: col-resize;
position: relative;
}
:deep(.splitpanes--vertical > .splitpanes__splitter:hover) {
background-color: hsl(var(--primary));
}
</style>

View File

@ -0,0 +1,355 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, shallowRef } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/clipboard";
import { useToast } from "@/composables/useToast";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
import { Splitpanes, Pane } from "splitpanes";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schemaDiff";
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap } from "@lucide/vue";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const { isDark } = useTheme();
const props = defineProps<{
deploySql: string;
selectedObjects: SchemaDiffObject[];
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
executing: boolean;
}>();
const emit = defineEmits<{
"update:deploySql": [sql: string];
back: [];
deploy: [];
}>();
const editorContainer = ref<HTMLDivElement>();
const editorView = shallowRef<any>(null);
const isEditorReady = ref(false);
const selectedObjectId = ref<string | null>(null);
// Parse object positions in deploySql
const objectPositions = computed(() => {
const positions = new Map<string, { from: number; to: number }>();
const sql = props.deploySql;
for (const obj of topLevelObjects.value) {
const patterns = [`-- Create ${obj.objectKind}: ${obj.name}`, `-- Modify ${obj.objectKind}: ${obj.name}`, `-- Drop ${obj.objectKind}: ${obj.name}`];
for (const pattern of patterns) {
const index = sql.indexOf(pattern);
if (index !== -1) {
let endPos = sql.length;
const remaining = sql.slice(index + pattern.length);
const nextMatch = remaining.match(/--\s*(Create|Modify|Drop)\s+\w+:/);
if (nextMatch && nextMatch.index !== undefined) {
endPos = index + pattern.length + nextMatch.index;
}
positions.set(obj.id, { from: index, to: endPos });
break;
}
}
}
return positions;
});
async function handleSelectObject(obj: SchemaDiffObject) {
selectedObjectId.value = obj.id;
if (editorView.value) {
const pos = objectPositions.value.get(obj.id);
if (pos) {
const { EditorView } = await import("@codemirror/view");
editorView.value.dispatch({
effects: EditorView.scrollIntoView(pos.from, { y: "start" }),
});
}
}
}
// Filter top-level selected objects (exclude children and none)
const topLevelObjects = computed(() => {
const operationOrder: Record<DiffOperationType, number> = { create: 0, modify: 1, delete: 2, none: 3 };
return props.selectedObjects
.filter((o) => {
const isTopLevel = !o.id.startsWith("col-") && !o.id.startsWith("idx-") && !o.id.startsWith("fk-") && !o.id.startsWith("trg-");
return o.selected && o.operationType !== "none" && isTopLevel;
})
.sort((a, b) => operationOrder[a.operationType] - operationOrder[b.operationType]);
});
const operationCounts = computed(() => {
const counts: Record<DiffOperationType, number> = { create: 0, modify: 0, delete: 0, none: 0 };
for (const obj of topLevelObjects.value) {
counts[obj.operationType]++;
}
return counts;
});
const operationIcons: Record<DiffOperationType, any> = {
modify: ArrowRightLeft,
create: PlusCircle,
delete: XCircle,
none: ArrowRightLeft,
};
const operationColors: Record<DiffOperationType, string> = {
modify: "text-blue-500",
create: "text-green-500",
delete: "text-red-500",
none: "text-muted-foreground",
};
// Initialize CodeMirror editor
async function initEditor() {
if (!editorContainer.value) return;
const [{ EditorView }, { EditorState, Compartment }, { sql, PostgreSQL, SQLDialect }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]);
const themeComp = new Compartment();
const fontComp = new Compartment();
const editorTheme = settingsStore.editorSettings.theme;
const appAppearance = isDark.value ? "dark" : "light";
const fontSize = settingsStore.editorSettings.fontSize;
const fontFamily = settingsStore.editorSettings.fontFamily;
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
// Custom PostgreSQL dialect with PL/pgSQL support (same as QueryEditor.vue)
const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
const plpgsqlKeywords = "PERFORM";
const plpgsqlTypes = " RECORD JSON JSONB";
const plpgsqlBuiltin = "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV";
const dialect = SQLDialect.define({
...PostgreSQL.spec,
keywords: [PostgreSQL.spec.keywords || "", extraKeywords, plpgsqlKeywords].filter(Boolean).join(" "),
types: [PostgreSQL.spec.types || "", plpgsqlTypes].filter(Boolean).join(" ") || undefined,
builtin: [PostgreSQL.spec.builtin || "", plpgsqlBuiltin].filter(Boolean).join(" ") || undefined,
doubleDollarQuotedStrings: false,
});
const state = EditorState.create({
doc: props.deploySql,
extensions: [
basicSetup,
sql({ dialect }),
themeComp.of(themeExt),
fontComp.of(fontExt),
EditorView.updateListener.of((update: any) => {
if (update.docChanged) {
emit("update:deploySql", update.state.doc.toString());
}
}),
],
});
editorView.value = new EditorView({ state, parent: editorContainer.value });
isEditorReady.value = true;
}
// Watch for external deploySql changes
watch(
() => props.deploySql,
(newVal) => {
if (editorView.value && editorView.value.state.doc.toString() !== newVal) {
editorView.value.dispatch({
changes: { from: 0, to: editorView.value.state.doc.length, insert: newVal },
});
}
},
);
onMounted(() => {
initEditor();
});
onUnmounted(() => {
editorView.value?.destroy();
editorView.value = null;
});
function handleCopy() {
copyToClipboard(props.deploySql);
toast(t("diff.copied"), 2000);
}
async function handleExport() {
try {
const dbName = props.targetDatabase || "deploy";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const defaultName = `${dbName}_deploy_${timestamp}.sql`;
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
defaultPath: defaultName,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (path) {
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, props.deploySql);
toast(t("diff.exportSuccess"), 3000);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
function handleDeploy() {
emit("deploy");
}
function getOperationLabel(type: DiffOperationType): string {
switch (type) {
case "create":
return t("diff.create");
case "delete":
return t("diff.delete");
case "modify":
return t("diff.modify");
default:
return "";
}
}
function getObjectIcon(kind: DiffObjectKind) {
switch (kind) {
case "table":
return Table;
case "view":
return Eye;
case "function":
return FunctionSquare;
case "sequence":
return ListOrdered;
case "rule":
return ScrollText;
case "owner":
return UserCog;
case "index":
return ListTree;
case "foreignKey":
return Link2;
case "trigger":
return Zap;
default:
return Table;
}
}
function getObjectIconColor(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "text-amber-500";
case "view":
return "text-cyan-500";
case "function":
return "text-purple-500";
case "sequence":
return "text-orange-500";
case "rule":
return "text-pink-500";
case "owner":
return "text-indigo-500";
case "index":
return "text-teal-500";
case "foreignKey":
return "text-lime-500";
case "trigger":
return "text-rose-500";
default:
return "text-muted-foreground";
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b shrink-0">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="$emit('back')">
<ArrowLeft class="w-3.5 h-3.5" />
{{ t("diff.backToResult") }}
</Button>
<span class="text-sm font-medium">{{ t("diff.deployReview") }}</span>
<span class="text-xs text-muted-foreground"> ({{ t("diff.selectedCount", { selected: topLevelObjects.length, total: topLevelObjects.length }) }}) </span>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="text-green-500">{{ t("diff.create") }}: {{ operationCounts.create }}</span>
<span class="text-blue-500">{{ t("diff.modify") }}: {{ operationCounts.modify }}</span>
<span class="text-red-500">{{ t("diff.delete") }}: {{ operationCounts.delete }}</span>
</div>
</div>
<!-- Content -->
<Splitpanes class="flex-1 min-h-0">
<Pane size="30" min-size="20">
<div class="h-full overflow-auto p-2 space-y-0.5">
<div v-for="obj in topLevelObjects" :key="obj.id" class="flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-accent/50 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === obj.id }" @click="handleSelectObject(obj)">
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5 shrink-0" :class="operationColors[obj.operationType]" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="truncate">{{ obj.name }}</span>
<span class="text-[10px] text-muted-foreground shrink-0 ml-auto">
{{ getOperationLabel(obj.operationType) }}
</span>
</div>
<div v-if="topLevelObjects.length === 0" class="text-xs text-muted-foreground text-center py-4">
{{ t("diff.noObjectsSelected") }}
</div>
</div>
</Pane>
<Pane size="70" min-size="40">
<div ref="editorContainer" class="h-full w-full" />
</Pane>
</Splitpanes>
<!-- Footer -->
<div class="flex items-center justify-between px-3 py-2 border-t shrink-0 gap-2">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="handleCopy">
<Copy class="w-3.5 h-3.5" />
{{ t("diff.copyScript") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="handleExport">
<Download class="w-3.5 h-3.5" />
{{ t("diff.exportSql") }}
</Button>
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-7 text-xs gap-1" @click="$emit('back')">
<ArrowLeft class="w-3.5 h-3.5" />
{{ t("diff.backToResult") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="$emit('back')">
{{ t("diff.cancel") }}
</Button>
<Button size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing" @click="handleDeploy">
<Loader2 v-if="executing" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
{{ t("diff.deployToServer") }}
</Button>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.splitpanes--vertical > .splitpanes__splitter) {
width: 4px;
background: hsl(var(--border));
cursor: col-resize;
}
:deep(.splitpanes--vertical > .splitpanes__splitter:hover) {
background: hsl(var(--primary));
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,245 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schemaDiff";
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
const { t } = useI18n();
interface ObjectTypeGroup {
kind: DiffObjectKind;
label: string;
objects: SchemaDiffObject[];
expanded: boolean;
}
interface OperationGroup {
operationType: DiffOperationType;
label: string;
count: number;
selectedCount: number;
expanded: boolean;
typeGroups: ObjectTypeGroup[];
}
const props = defineProps<{
groups: OperationGroup[];
selectedObjectId?: string | null;
}>();
const emit = defineEmits<{
(e: "toggleGroup", operationType: DiffOperationType): void;
(e: "toggleTypeGroup", operationType: DiffOperationType, kind: DiffObjectKind): void;
(e: "toggleGroupSelection", operationType: DiffOperationType, selected: boolean): void;
(e: "toggleTypeSelection", operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean): void;
(e: "toggleObjectSelection", objectId: string, selected: boolean): void;
(e: "selectObject", object: SchemaDiffObject): void;
}>();
const operationIcons: Record<DiffOperationType, any> = {
modify: ArrowRightLeft,
create: PlusCircle,
delete: XCircle,
none: MinusCircle,
};
const operationColors: Record<DiffOperationType, string> = {
modify: "text-blue-500",
create: "text-green-500",
delete: "text-red-500",
none: "text-muted-foreground",
};
const operationBgColors: Record<DiffOperationType, string> = {
modify: "bg-blue-500/10 border-blue-500/20",
create: "bg-green-500/10 border-green-500/20",
delete: "bg-red-500/10 border-red-500/20",
none: "bg-muted/30 border-muted",
};
function getObjectIcon(kind: DiffObjectKind) {
switch (kind) {
case "table":
return Table;
case "view":
return Eye;
case "function":
return FunctionSquare;
case "sequence":
return ListOrdered;
case "rule":
return ScrollText;
case "owner":
return UserCog;
case "index":
return ListTree;
case "foreignKey":
return Link2;
case "trigger":
return Zap;
default:
return Table;
}
}
function getObjectIconColor(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "text-amber-500";
case "view":
return "text-cyan-500";
case "function":
return "text-purple-500";
case "sequence":
return "text-orange-500";
case "rule":
return "text-pink-500";
case "owner":
return "text-indigo-500";
case "index":
return "text-teal-500";
case "foreignKey":
return "text-lime-500";
case "trigger":
return "text-rose-500";
default:
return "text-muted-foreground";
}
}
function getObjectTypeLabel(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "diff.objectKindLabel.table";
case "view":
return "diff.objectKindLabel.view";
case "function":
return "diff.objectKindLabel.function";
case "sequence":
return "diff.objectKindLabel.sequence";
case "rule":
return "diff.objectKindLabel.rule";
case "owner":
return "diff.objectKindLabel.owner";
case "index":
return "diff.objectKindLabel.index";
case "foreignKey":
return "diff.objectKindLabel.foreignKey";
case "trigger":
return "diff.objectKindLabel.trigger";
default:
return kind;
}
}
function isGroupFullySelected(group: OperationGroup): boolean {
return group.count > 0 && group.selectedCount === group.count;
}
function isGroupPartiallySelected(group: OperationGroup): boolean {
return group.selectedCount > 0 && group.selectedCount < group.count;
}
function isTypeGroupFullySelected(typeGroup: ObjectTypeGroup): boolean {
return typeGroup.objects.length > 0 && typeGroup.objects.every((o) => o.selected);
}
function isTypeGroupPartiallySelected(typeGroup: ObjectTypeGroup): boolean {
const selectedCount = typeGroup.objects.filter((o) => o.selected).length;
return selectedCount > 0 && selectedCount < typeGroup.objects.length;
}
function onGroupCheckboxChange(group: OperationGroup, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleGroupSelection", group.operationType, checked);
}
function onTypeCheckboxChange(group: OperationGroup, typeGroup: ObjectTypeGroup, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleTypeSelection", group.operationType, typeGroup.kind, checked);
}
function onObjectCheckboxChange(obj: SchemaDiffObject, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleObjectSelection", obj.id, checked);
}
function formatObjectName(obj: SchemaDiffObject): string {
if (obj.objectKind === "function" && obj.arguments) {
return `${obj.name}(${obj.arguments})`;
}
return obj.name;
}
</script>
<template>
<div class="space-y-1">
<!-- Header -->
<div class="grid grid-cols-[1fr_60px_1fr] gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground border-b">
<div class="text-center">{{ t("diff.sourceObject") }}</div>
<div class="text-center">{{ t("diff.operation") }}</div>
<div class="text-center">{{ t("diff.targetObject") }}</div>
</div>
<!-- Operation Groups -->
<div v-for="group in groups" :key="group.operationType" class="border rounded-md overflow-hidden">
<!-- Operation Group Header -->
<button class="flex items-center gap-2 w-full px-3 py-2 text-sm font-medium transition-colors" :class="operationBgColors[group.operationType]" @click="$emit('toggleGroup', group.operationType)">
<ChevronDown v-if="group.expanded" class="w-4 h-4 shrink-0" />
<ChevronRight v-else class="w-4 h-4 shrink-0" />
<input type="checkbox" class="accent-primary shrink-0" :checked="isGroupFullySelected(group)" :indeterminate="isGroupPartiallySelected(group)" @click.stop @change="onGroupCheckboxChange(group, $event)" />
<component :is="operationIcons[group.operationType]" class="w-4 h-4 shrink-0" :class="operationColors[group.operationType]" />
<span :class="operationColors[group.operationType]">{{ t(group.label) }}</span>
<span class="text-xs text-muted-foreground ml-1"> ({{ t("diff.selectedCount", { selected: group.selectedCount, total: group.count }) }}) </span>
</button>
<!-- Type Groups -->
<div v-if="group.expanded" class="divide-y divide-border/30">
<div v-for="typeGroup in group.typeGroups" :key="typeGroup.kind" class="border-l-2 border-l-border/50 ml-2">
<!-- Type Group Header -->
<button class="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium hover:bg-accent/20 transition-colors" @click="$emit('toggleTypeGroup', group.operationType, typeGroup.kind)">
<ChevronDown v-if="typeGroup.expanded" class="w-3.5 h-3.5 shrink-0" />
<ChevronRight v-else class="w-3.5 h-3.5 shrink-0" />
<input type="checkbox" class="accent-primary shrink-0" :checked="isTypeGroupFullySelected(typeGroup)" :indeterminate="isTypeGroupPartiallySelected(typeGroup)" @click.stop @change="onTypeCheckboxChange(group, typeGroup, $event)" />
<component :is="getObjectIcon(typeGroup.kind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(typeGroup.kind)" />
<span>{{ t(getObjectTypeLabel(typeGroup.kind)) }}</span>
<span class="text-xs text-muted-foreground">({{ typeGroup.objects.length }})</span>
</button>
<!-- Objects -->
<div v-if="typeGroup.expanded" class="divide-y divide-border/20">
<div v-for="obj in typeGroup.objects" :key="obj.id" class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1 items-center hover:bg-accent/30 cursor-pointer ml-8" :class="{ 'bg-primary/10': selectedObjectId === obj.id }" @click="$emit('selectObject', obj)">
<!-- Source (hide for delete objects) -->
<div v-if="obj.operationType !== 'delete'" class="flex items-center gap-2 min-w-0">
<input type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'create' ? 'text-green-500' : ''">
{{ obj.sourceName ? (obj.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
</span>
</div>
<div v-else></div>
<!-- Operation -->
<div class="flex justify-center">
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5" :class="operationColors[obj.operationType]" />
</div>
<!-- Target (hide for create objects) -->
<div v-if="obj.operationType !== 'create'" class="flex items-center gap-2 min-w-0">
<input v-if="obj.operationType === 'delete'" type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'delete' ? 'text-red-500 line-through' : ''">
{{ obj.targetName ? (obj.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
</span>
</div>
<div v-else></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import type { SchemaDiffCompareOptions, SchemaDiffOptionItem } from "@/types/schemaDiff";
const props = defineProps<{
options: SchemaDiffCompareOptions;
optionTree: SchemaDiffOptionItem[];
}>();
const emit = defineEmits<{
(e: "update:options", options: SchemaDiffCompareOptions): void;
(e: "close"): void;
}>();
const { t } = useI18n();
// Local copy of options
const localOptions = ref<SchemaDiffCompareOptions>({ ...props.options });
// Watch for external changes
watch(
() => props.options,
(newOptions) => {
localOptions.value = { ...newOptions };
},
{ deep: true },
);
function isChecked(id: keyof SchemaDiffCompareOptions): boolean {
return !!localOptions.value[id];
}
function setOption(id: keyof SchemaDiffCompareOptions, checked: boolean) {
localOptions.value = { ...localOptions.value, [id]: checked };
}
function getChildState(item: SchemaDiffOptionItem): "checked" | "unchecked" | "indeterminate" {
if (!item.children || item.children.length === 0) {
return isChecked(item.id) ? "checked" : "unchecked";
}
const childStates = item.children.map((child) => getChildState(child));
if (childStates.every((s) => s === "checked")) return "checked";
if (childStates.every((s) => s === "unchecked")) return "unchecked";
return "indeterminate";
}
function toggleItem(item: SchemaDiffOptionItem) {
const state = getChildState(item);
const nextChecked = state !== "checked";
setSubtree(item, nextChecked);
}
function setSubtree(item: SchemaDiffOptionItem, checked: boolean) {
setOption(item.id, checked);
if (item.children) {
for (const child of item.children) {
setSubtree(child, checked);
}
}
}
function handleDone() {
emit("update:options", { ...localOptions.value });
emit("close");
}
function handleCancel() {
emit("close");
}
function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): string {
const base = "h-4 w-4 rounded border flex items-center justify-center transition-colors cursor-pointer";
if (state === "checked") {
return `${base} bg-primary border-primary text-primary-foreground`;
}
if (state === "indeterminate") {
return `${base} bg-primary border-primary text-primary-foreground`;
}
return `${base} bg-background border-input hover:border-muted-foreground`;
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="flex-1 overflow-auto space-y-1">
<template v-for="item in optionTree" :key="item.id">
<div class="space-y-1">
<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(item)">
<div :class="getItemClasses(getChildState(item))">
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<polyline points="20 6 9 17 4 12" />
</svg>
<svg v-else-if="getChildState(item) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</div>
<span class="text-sm select-none">{{ t(item.labelKey) }}</span>
</div>
<div v-if="item.children" class="ml-6 space-y-1">
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(child)">
<div :class="getItemClasses(getChildState(child))">
<svg v-if="getChildState(child) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span class="text-sm select-none">{{ t(child.labelKey) }}</span>
</div>
</div>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-2 pt-4 border-t mt-2">
<Button variant="outline" size="sm" @click="handleCancel">
{{ t("common.cancel") }}
</Button>
<Button size="sm" @click="handleDone">
{{ t("common.done") }}
</Button>
</div>
</div>
</template>

View File

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import type { CustomTheme, CustomThemeColors } from "@/stores/settingsStore";
import { DEFAULT_CUSTOM_THEME_COLORS } from "@/stores/settingsStore";
import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
import { Plus, Trash2, Copy, Pencil, ChevronDown, Palette } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useI18n } from "vue-i18n";
@ -367,6 +367,7 @@ function handleAddTheme() {
id,
name,
colors: { ...DEFAULT_CUSTOM_THEME_COLORS },
ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS },
});
activeEditId.value = id;
}
@ -388,6 +389,7 @@ function handleDuplicateTheme(theme: CustomTheme) {
id,
name: `${theme.name}${t("settings.customThemeCopySuffix")}`,
colors: { ...theme.colors },
ddlColors: { ...(theme.ddlColors ?? DEFAULT_CUSTOM_THEME_DDL_COLORS) },
});
activeEditId.value = id;
}

View File

@ -66,12 +66,13 @@ function onToolbarDblClick(e: MouseEvent) {
const toolbarEl = ref<HTMLElement>();
const toolbarCollapsed = ref(false);
const COLLAPSE_THRESHOLD = 1000;
function checkToolbarWidth() {
const el = toolbarEl.value;
if (!el) return;
toolbarCollapsed.value = el.clientWidth < COLLAPSE_THRESHOLD;
const screenWidth = window.visualViewport?.width ?? window.innerWidth;
const threshold = screenWidth / 2;
toolbarCollapsed.value = el.clientWidth < threshold;
}
let resizeObserver: ResizeObserver | null = null;
@ -79,10 +80,12 @@ let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
resizeObserver = new ResizeObserver(checkToolbarWidth);
if (toolbarEl.value) resizeObserver.observe(toolbarEl.value);
window.addEventListener("resize", checkToolbarWidth);
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
window.removeEventListener("resize", checkToolbarWidth);
});
const moreItems = computed(() => {

View File

@ -0,0 +1,52 @@
import { ref, type Ref } from "vue";
import type { DiffHunk } from "@/components/diff/DiffHunkBuilder";
export interface UseDiffScrollSyncOptions {
container: Ref<HTMLElement | undefined>;
leftPane: Ref<HTMLElement | undefined>;
rightPane: Ref<HTMLElement | undefined>;
hunks: Ref<DiffHunk[]>;
}
export function useDiffScrollSync({ container, leftPane, rightPane, hunks }: UseDiffScrollSyncOptions) {
const isSyncingScroll = ref(false);
function syncScroll(from: "left" | "right") {
if (isSyncingScroll.value) return;
const source = from === "left" ? leftPane.value : rightPane.value;
const target = from === "left" ? rightPane.value : leftPane.value;
if (!source || !target) return;
isSyncingScroll.value = true;
target.scrollTop = source.scrollTop;
isSyncingScroll.value = false;
}
function measureHunks() {
const outer = container.value;
const left = leftPane.value;
const right = rightPane.value;
if (!outer || !left || !right) return;
const outerRect = outer.getBoundingClientRect();
for (const hunk of hunks.value) {
const leftEl = left.querySelector(`[data-hunk-id="${hunk.id}"]`) as HTMLElement | null;
const rightEl = right.querySelector(`[data-hunk-id="${hunk.id}"]`) as HTMLElement | null;
if (leftEl) {
const rect = leftEl.getBoundingClientRect();
hunk.leftTop = rect.top - outerRect.top;
hunk.leftBottom = rect.bottom - outerRect.top;
}
if (rightEl) {
const rect = rightEl.getBoundingClientRect();
hunk.rightTop = rect.top - outerRect.top;
hunk.rightBottom = rect.bottom - outerRect.top;
}
}
}
return {
syncScroll,
measureHunks,
};
}

View File

@ -0,0 +1,252 @@
import { ref, computed, watch } from "vue";
import { uuid } from "@/lib/utils";
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
import { createEmptyConfig, getDefaultOptionsForDbType } from "@/types/schemaDiff";
const STORAGE_KEY = "dbx-schema-diff-configs";
const HISTORY_KEY = "dbx-schema-diff-history";
const MAX_HISTORY = 10;
const configs = ref<SchemaDiffConfig[]>(loadConfigsFromStorage());
const activeConfigId = ref<string>("");
const recentConfigs = ref<SchemaDiffConfig[]>(loadHistoryFromStorage());
function loadConfigsFromStorage(): SchemaDiffConfig[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as SchemaDiffConfig[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function loadHistoryFromStorage(): SchemaDiffConfig[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as SchemaDiffConfig[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveConfigsToStorage() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs.value));
}
function saveHistoryToStorage() {
localStorage.setItem(HISTORY_KEY, JSON.stringify(recentConfigs.value));
}
function createDefaultConfig(dbType?: string): SchemaDiffConfig {
const id = uuid();
const config = createEmptyConfig(id, "Default");
if (dbType) {
config.options = getDefaultOptionsForDbType(dbType);
}
return config;
}
export function useSchemaDiffConfig() {
const activeConfig = computed(() => configs.value.find((c) => c.id === activeConfigId.value) ?? configs.value[0] ?? null);
function ensureDefaultConfig(dbType?: string) {
if (configs.value.length === 0) {
const defaultConfig = createDefaultConfig(dbType);
configs.value.push(defaultConfig);
activeConfigId.value = defaultConfig.id;
saveConfigsToStorage();
} else if (!activeConfigId.value) {
activeConfigId.value = configs.value[0].id;
}
}
function createConfig(name: string, base?: SchemaDiffConfig, dbType?: string): SchemaDiffConfig {
const id = uuid();
const config = base ? { ...base, id, name, createdAt: Date.now(), updatedAt: Date.now() } : createEmptyConfig(id, name);
if (dbType && !base) {
config.options = getDefaultOptionsForDbType(dbType);
}
configs.value.push(config);
activeConfigId.value = id;
saveConfigsToStorage();
return config;
}
function updateConfig(id: string, updates: Partial<Omit<SchemaDiffConfig, "id" | "createdAt">>) {
const index = configs.value.findIndex((c) => c.id === id);
if (index === -1) return;
configs.value[index] = {
...configs.value[index],
...updates,
updatedAt: Date.now(),
};
saveConfigsToStorage();
}
function renameConfig(id: string, name: string) {
updateConfig(id, { name });
}
function deleteConfig(id: string) {
configs.value = configs.value.filter((c) => c.id !== id);
if (activeConfigId.value === id) {
activeConfigId.value = configs.value[0]?.id ?? "";
}
saveConfigsToStorage();
}
function duplicateConfig(id: string) {
const source = configs.value.find((c) => c.id === id);
if (!source) return;
let newName = `${source.name} (1)`;
let counter = 1;
while (configs.value.some((c) => c.name === newName)) {
counter++;
newName = `${source.name} (${counter})`;
}
createConfig(newName, source);
}
function exportConfigs(): string {
return JSON.stringify(configs.value, null, 2);
}
function exportActiveConfig(): string {
if (!activeConfig.value) return "";
return JSON.stringify(activeConfig.value, null, 2);
}
function importConfigs(
jsonText: string,
mode: "merge" | "replace" = "merge",
): {
imported: number;
renamed: number;
} {
const parsed = JSON.parse(jsonText) as SchemaDiffConfig | SchemaDiffConfig[];
const items = Array.isArray(parsed) ? parsed : [parsed];
let renamed = 0;
if (mode === "replace") {
configs.value = [];
}
for (const item of items) {
let name = item.name;
if (configs.value.some((c) => c.name === name && c.id !== item.id)) {
let counter = 1;
let candidate = `${name} (${counter})`;
while (configs.value.some((c) => c.name === candidate)) {
counter++;
candidate = `${name} (${counter})`;
}
name = candidate;
renamed++;
}
const newId = uuid();
configs.value.push({
...item,
id: newId,
name,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
saveConfigsToStorage();
if (!activeConfigId.value && configs.value.length > 0) {
activeConfigId.value = configs.value[0].id;
}
return { imported: items.length, renamed };
}
function updateActiveConfigOptions(options: SchemaDiffCompareOptions) {
if (!activeConfig.value) return;
updateConfig(activeConfig.value.id, { options: { ...options } });
}
function updateActiveConfigConnection(updates: Partial<SchemaDiffConfig>) {
if (!activeConfig.value) return;
const filtered: Partial<SchemaDiffConfig> = {};
if (updates.sourceConnectionId !== undefined) filtered.sourceConnectionId = updates.sourceConnectionId;
if (updates.sourceDatabase !== undefined) filtered.sourceDatabase = updates.sourceDatabase;
if (updates.sourceSchema !== undefined) filtered.sourceSchema = updates.sourceSchema;
if (updates.targetConnectionId !== undefined) filtered.targetConnectionId = updates.targetConnectionId;
if (updates.targetDatabase !== undefined) filtered.targetDatabase = updates.targetDatabase;
if (updates.targetSchema !== undefined) filtered.targetSchema = updates.targetSchema;
updateConfig(activeConfig.value.id, filtered);
}
function saveToHistory(config: SchemaDiffConfig) {
// Check if same config already exists (same connection/db/schema)
const existingIndex = recentConfigs.value.findIndex(
(c) => c.sourceConnectionId === config.sourceConnectionId && c.sourceDatabase === config.sourceDatabase && c.sourceSchema === config.sourceSchema && c.targetConnectionId === config.targetConnectionId && c.targetDatabase === config.targetDatabase && c.targetSchema === config.targetSchema,
);
if (existingIndex !== -1) {
// Update existing entry
recentConfigs.value[existingIndex] = {
...config,
id: recentConfigs.value[existingIndex].id,
updatedAt: Date.now(),
};
// Move to front
const updated = recentConfigs.value.splice(existingIndex, 1)[0];
recentConfigs.value.unshift(updated);
} else {
// Add new entry
recentConfigs.value.unshift({
...config,
id: uuid(),
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
// Keep only MAX_HISTORY items
if (recentConfigs.value.length > MAX_HISTORY) {
recentConfigs.value = recentConfigs.value.slice(0, MAX_HISTORY);
}
saveHistoryToStorage();
}
function loadFromHistory(configId: string) {
const config = recentConfigs.value.find((c) => c.id === configId);
if (!config) return null;
return config;
}
function deleteFromHistory(configId: string) {
recentConfigs.value = recentConfigs.value.filter((c) => c.id !== configId);
saveHistoryToStorage();
}
watch(configs, saveConfigsToStorage, { deep: true });
return {
configs: computed(() => configs.value),
activeConfigId,
activeConfig,
recentConfigs: computed(() => recentConfigs.value),
ensureDefaultConfig,
createConfig,
updateConfig,
renameConfig,
deleteConfig,
duplicateConfig,
exportConfigs,
exportActiveConfig,
importConfigs,
updateActiveConfigOptions,
updateActiveConfigConnection,
saveToHistory,
loadFromHistory,
deleteFromHistory,
};
}

View File

@ -1679,12 +1679,17 @@
},
},
diff: {
title: "Compare Databases",
title: "Compare Schemas",
source: "Source",
target: "Target",
selectConnection: "Select connection",
selectDatabase: "Select database",
selectSchema: "Select schema",
connection: "Connection",
database: "Database",
schema: "Schema",
recentConfigs: "Recent Configs",
selectRecentConfig: "Select recent config",
ignoreComments: "Ignore comment differences",
compare: "Compare",
comparing: "Comparing schemas...",
@ -1708,6 +1713,92 @@
swap: "Swap source and target",
syncProgress: "Executing: {current}/{total}",
syncSummary: "Succeeded {success}, failed {failed}",
configSaved: "Config saved",
selectObjectToCompare: "Select an object to view DDL comparison",
noDdlAvailable: "No DDL available",
sourceDdl: "Source DDL",
targetDdl: "Target DDL",
objectNotExistsInSource: "This object does not exist in the source database",
objectNotExistsInTarget: "This object does not exist in the target database",
nextStepDeploy: "Next (Review Deploy)",
deployReview: "Deploy Review",
backToResult: "Back to Result",
copyScript: "Copy Script",
exportSql: "Export .sql",
deployToServer: "Deploy to Server",
deployConfirmTitle: "Final Confirmation",
deployConfirmMessage: "You are about to deploy the following changes to the target database",
targetServer: "Target Server",
dbVersion: "Database Version",
targetDatabase: "Target Database",
targetSchema: "Target Schema",
useTransaction: "Execute in transaction (auto-rollback on failure)",
confirmDeploy: "Confirm Deploy",
exportSuccess: "Export successful",
create: "Create",
delete: "Delete",
modify: "Modify",
cancel: "Cancel",
operationLabel: {
modify: "Objects to modify",
create: "Objects to create",
delete: "Objects to delete",
none: "No operation",
},
objectKindLabel: {
table: "Table",
view: "View",
function: "Function",
sequence: "Sequence",
rule: "Rule",
owner: "Owner",
index: "Index",
foreignKey: "Foreign Key",
trigger: "Trigger",
},
noObjectsSelected: "Please select objects to deploy",
deploySuccess: "Deploy Successful",
deployFailed: "Deploy Failed",
deploySuccessMessage: "SQL script executed successfully on target database",
deployFailedMessage: "An error occurred during execution",
affectedRows: "Affected Rows",
executedStatements: "Executed Statements",
serverVersion: "Server Version",
close: "Close",
},
schemaDiff: {
optionsTitle: "Compare Options",
selectConfig: "Select config",
newConfigName: "New Config",
newConfig: "New Config",
renameConfig: "Rename Config",
duplicateConfig: "Duplicate Config",
deleteConfig: "Delete Config",
exportConfig: "Export Config",
exportAllConfigs: "Export All Configs",
importConfig: "Import Config",
importFromFile: "Import from File",
importFromText: "Import from Text",
importPlaceholder: "Paste JSON config...",
importInvalidJson: "Invalid JSON format",
configName: "Config Name",
options: {
tables: "Compare tables",
primaryKeys: "Compare primary keys",
foreignKeys: "Compare foreign keys",
uniqueKeys: "Compare unique keys",
checks: "Compare check constraints",
exclusions: "Compare exclusion constraints",
views: "Compare views",
functions: "Compare functions",
indexes: "Compare indexes",
sequences: "Compare sequences",
triggers: "Compare triggers",
rules: "Compare rules",
owners: "Compare owners",
cascadeDelete: "Use CASCADE delete",
sequenceLastValues: "Compare sequence last values",
},
},
dataCompare: {
title: "Compare Data",

View File

@ -1438,7 +1438,7 @@
},
},
diff: {
title: "Comparar bases de datos",
title: "Comparar esquemas",
source: "Origen",
target: "Destino",
selectConnection: "Seleccionar conexión",
@ -1467,6 +1467,57 @@
swap: "Intercambiar origen y destino",
syncProgress: "Ejecutando: {current}/{total}",
syncSummary: "Exitosos {success}, fallidos {failed}",
operationLabel: {
modify: "Objetos a modificar",
create: "Objetos a crear",
delete: "Objetos a eliminar",
none: "Sin operación",
},
objectKindLabel: {
table: "Tabla",
view: "Vista",
function: "Función",
sequence: "Secuencia",
rule: "Regla",
owner: "Propietario",
index: "Índice",
foreignKey: "Clave foránea",
trigger: "Disparador",
},
},
schemaDiff: {
optionsTitle: "Opciones de comparación",
selectConfig: "Select config",
newConfigName: "New Config",
newConfig: "New Config",
renameConfig: "Rename Config",
duplicateConfig: "Duplicate Config",
deleteConfig: "Delete Config",
exportConfig: "Export Config",
exportAllConfigs: "Export All Configs",
importConfig: "Import Config",
importFromFile: "Import from File",
importFromText: "Import from Text",
importPlaceholder: "Paste JSON config...",
importInvalidJson: "Invalid JSON format",
configName: "Config Name",
options: {
tables: "Compare tables",
primaryKeys: "Compare primary keys",
foreignKeys: "Compare foreign keys",
uniqueKeys: "Compare unique keys",
checks: "Compare check constraints",
exclusions: "Compare exclusion constraints",
views: "Compare views",
functions: "Compare functions",
indexes: "Compare indexes",
sequences: "Compare sequences",
triggers: "Compare triggers",
rules: "Compare rules",
owners: "Compare owners",
cascadeDelete: "Use CASCADE delete",
sequenceLastValues: "Compare sequence last values",
},
},
dataCompare: {
title: "Comparar datos",

View File

@ -1555,7 +1555,7 @@
},
},
diff: {
title: "Confronta Database",
title: "Confronta Schemi",
source: "Sorgente",
target: "Destinazione",
selectConnection: "Seleziona connessione",

View File

@ -1566,7 +1566,7 @@
},
},
diff: {
title: "Comparar Bancos de Dados",
title: "Comparar Esquemas",
source: "Origem",
target: "Destino",
selectConnection: "Selecionar conexão",

View File

@ -1678,12 +1678,17 @@
},
},
diff: {
title: "比较数据库",
title: "比较架构",
source: "源数据库",
target: "目标数据库",
selectConnection: "选择连接",
selectDatabase: "选择数据库",
selectSchema: "选择 Schema",
connection: "连接",
database: "数据库",
schema: "模式",
recentConfigs: "最近配置",
selectRecentConfig: "选择最近配置",
ignoreComments: "忽略注释差异",
compare: "开始比较",
comparing: "正在比较结构...",
@ -1707,6 +1712,117 @@
swap: "交换源和目标",
syncProgress: "执行中: {current}/{total}",
syncSummary: "成功 {success} 条,失败 {failed} 条",
sourceObject: "源对象",
targetObject: "目标对象",
operation: "操作",
selectedCount: "已选择 {selected} 个 (共 {total} 个)",
saveConfig: "保存配置",
loadConfig: "加载配置",
options: "选项",
ddlCompare: "DDL 比较",
deployScript: "部署脚本",
selectObjectToCompare: "选择对象查看 DDL 对比",
noDdlAvailable: "无可用的 DDL",
objectNotExistsInSource: "此对象在源库中不存在",
objectNotExistsInTarget: "此对象在目标库中不存在",
sourceDdl: "源 DDL",
targetDdl: "目标 DDL",
deployScriptTitle: "部署脚本",
deployScriptAll: "全部部署脚本",
deployScriptAllTitle: "全部部署脚本",
copyScript: "复制脚本",
executeScript: "执行脚本",
executeSuccess: "执行成功",
prevStep: "上一步",
recompare: "重新比较",
deploy: "下一步",
nextStepDeploy: "下一步(审核部署)",
deployReview: "部署审核",
backToResult: "返回结果",
exportSql: "导出 .sql",
deployToServer: "部署到服务器",
deployConfirmTitle: "最终确认",
deployConfirmMessage: "您即将在目标数据库执行以下部署操作",
targetServer: "目标服务器",
dbVersion: "数据库版本",
targetDatabase: "目标数据库",
targetSchema: "目标 Schema",
useTransaction: "在事务中执行(失败自动回滚)",
confirmDeploy: "确认部署",
exportSuccess: "导出成功",
create: "创建",
delete: "删除",
modify: "修改",
cancel: "取消",
operationLabel: {
modify: "要修改的对象",
create: "要创建的对象",
delete: "要删除的对象",
none: "无操作",
},
objectKindLabel: {
table: "表",
view: "视图",
function: "函数",
sequence: "序列",
rule: "规则",
owner: "所有者",
index: "索引",
foreignKey: "外键",
trigger: "触发器",
},
noObjectsSelected: "请选择要部署的对象",
deploySuccess: "部署成功",
deployFailed: "部署失败",
deploySuccessMessage: "SQL 脚本已成功执行到目标数据库",
deployFailedMessage: "执行过程中发生错误",
affectedRows: "影响行数",
executedStatements: "执行语句数",
close: "关闭",
info: "信息",
connType: "连接类型",
connName: "连接名称",
host: "主机",
port: "端口",
serverVersion: "服务器版本",
copied: "已复制到剪贴板",
configSaved: "配置已保存",
configDeleted: "配置已删除",
saveConfigPrompt: "请输入配置名称:",
},
schemaDiff: {
optionsTitle: "比较选项",
selectConfig: "选择配置",
newConfigName: "新配置",
newConfig: "新建配置",
renameConfig: "重命名配置",
duplicateConfig: "复制配置",
deleteConfig: "删除配置",
exportConfig: "导出配置",
exportAllConfigs: "导出全部配置",
importConfig: "导入配置",
importFromFile: "从文件导入",
importFromText: "从文本导入",
importPlaceholder: "粘贴 JSON 配置...",
importInvalidJson: "无效的 JSON 格式",
configName: "配置名称",
options: {
tables: "比较表",
primaryKeys: "比较主键",
foreignKeys: "比较外键",
uniqueKeys: "比较唯一键",
checks: "比较检查约束",
exclusions: "比较排除约束",
views: "比较视图",
functions: "比较函数",
indexes: "比较索引",
sequences: "比较序列",
triggers: "比较触发器",
rules: "比较规则",
owners: "比较所有者",
cascadeDelete: "使用级联删除",
sequenceLastValues: "比较序列最后值",
},
},
dataCompare: {
title: "比较数据",

View File

@ -1543,7 +1543,7 @@
},
},
diff: {
title: "比較資料庫",
title: "比較架構",
source: "來源資料庫",
target: "目標資料庫",
selectConnection: "選擇連線",
@ -1572,6 +1572,57 @@
swap: "交換來源與目標",
syncProgress: "執行中: {current}/{total}",
syncSummary: "成功 {success} 條,失敗 {failed} 條",
operationLabel: {
modify: "要修改的物件",
create: "要建立的物件",
delete: "要刪除的物件",
none: "無操作",
},
objectKindLabel: {
table: "資料表",
view: "視圖",
function: "函數",
sequence: "序列",
rule: "規則",
owner: "擁有者",
index: "索引",
foreignKey: "外鍵",
trigger: "觸發器",
},
},
schemaDiff: {
optionsTitle: "比較選項",
selectConfig: "選擇配置",
newConfigName: "新配置",
newConfig: "新建配置",
renameConfig: "重新命名配置",
duplicateConfig: "複製配置",
deleteConfig: "刪除配置",
exportConfig: "匯出配置",
exportAllConfigs: "匯出全部配置",
importConfig: "匯入配置",
importFromFile: "從檔案匯入",
importFromText: "從文字匯入",
importPlaceholder: "貼上 JSON 配置...",
importInvalidJson: "無效的 JSON 格式",
configName: "配置名稱",
options: {
tables: "比較資料表",
primaryKeys: "比較主鍵",
foreignKeys: "比較外鍵",
uniqueKeys: "比較唯一鍵",
checks: "比較檢查約束",
exclusions: "比較排除約束",
views: "比較視圖",
functions: "比較函數",
indexes: "比較索引",
sequences: "比較序列",
triggers: "比較觸發器",
rules: "比較規則",
owners: "比較擁有者",
cascadeDelete: "使用級聯刪除",
sequenceLastValues: "比較序列最後值",
},
},
dataCompare: {
title: "比較資料",

View File

@ -113,6 +113,10 @@ export const listIndexes = forward("listIndexes");
export const listForeignKeys = forward("listForeignKeys");
export const listTriggers = forward("listTriggers");
export const getTableDdl = forward("getTableDdl");
export const listFunctions = forward("listFunctions");
export const listSequences = forward("listSequences");
export const listRules = forward("listRules");
export const listOwners = forward("listOwners");
export const prepareSchemaDiff = forward("prepareSchemaDiff");
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");

View File

@ -9,6 +9,10 @@ import type {
IndexInfo,
ForeignKeyInfo,
TriggerInfo,
FunctionInfo,
SequenceInfo,
RuleInfo,
OwnerInfo,
QueryResult,
SqlReferenceAnalysis,
DatabaseType,
@ -21,6 +25,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
} from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schemaDiff";
import type { SidebarObjectKind } from "@/lib/databaseObjectCapabilities";
import type { AiConfig } from "@/stores/settingsStore";
import type {
@ -84,7 +89,6 @@ import type { CreateDatabaseSqlOptions } from "@/lib/createDatabaseSql";
import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/dbAdminSql";
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataCompare";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff } from "@/lib/schemaDiff";
import type { DataGridSavePreparation } from "./tauri";
// ---------------------------------------------------------------------------
@ -443,8 +447,33 @@ export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions):
return post("/api/schema-diff/prepare", options);
}
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string): Promise<string> {
return post("/api/schema-diff/generate-sync-sql", { diffs, databaseType, targetSchema });
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string, functionDiffs?: FunctionDiff[], sequenceDiffs?: SequenceDiff[], ruleDiffs?: RuleDiff[], ownerDiffs?: OwnerDiff[], cascadeDelete?: boolean): Promise<string> {
return post("/api/schema-diff/generate-sync-sql", {
diffs,
databaseType,
targetSchema,
functionDiffs: functionDiffs ?? [],
sequenceDiffs: sequenceDiffs ?? [],
ruleDiffs: ruleDiffs ?? [],
ownerDiffs: ownerDiffs ?? [],
cascadeDelete: cascadeDelete ?? false,
});
}
export async function listFunctions(connectionId: string, database: string, schema: string): Promise<FunctionInfo[]> {
return get(`/api/schema/functions?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function listSequences(connectionId: string, database: string, schema: string, withLastValues: boolean): Promise<SequenceInfo[]> {
return get(`/api/schema/sequences?${qs({ connection_id: connectionId, database, schema, with_last_values: withLastValues ? 1 : 0 })}`);
}
export async function listRules(connectionId: string, database: string, schema: string): Promise<RuleInfo[]> {
return get(`/api/schema/rules?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function listOwners(connectionId: string, database: string, schema: string): Promise<OwnerInfo[]> {
return get(`/api/schema/owners?${qs({ connection_id: connectionId, database, schema })}`);
}
// ---------------------------------------------------------------------------

View File

@ -1,4 +1,4 @@
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, DatabaseType, TableInfo } from "@/types/database";
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
export interface ColumnDiff {
type: "added" | "removed" | "modified";
@ -32,6 +32,38 @@ export interface TriggerDiff {
changes?: string[];
}
export interface FunctionDiff {
type: "added" | "removed" | "modified";
name: string;
source?: FunctionInfo;
target?: FunctionInfo;
changes?: string[];
}
export interface SequenceDiff {
type: "added" | "removed" | "modified";
name: string;
source?: SequenceInfo;
target?: SequenceInfo;
changes?: string[];
}
export interface RuleDiff {
type: "added" | "removed" | "modified";
name: string;
source?: RuleInfo;
target?: RuleInfo;
changes?: string[];
}
export interface OwnerDiff {
type: "added" | "removed" | "modified";
objectName: string;
source?: OwnerInfo;
target?: OwnerInfo;
changes?: string[];
}
export interface TableDiff {
type: "added" | "removed" | "modified";
objectType?: "table" | "view";
@ -41,6 +73,7 @@ export interface TableDiff {
foreignKeys?: ForeignKeyDiff[];
triggers?: TriggerDiff[];
ddl?: string;
targetDdl?: string;
sourceTableComment?: string | null;
targetTableComment?: string | null;
}
@ -59,12 +92,344 @@ export interface SchemaDiffPreparationOptions {
targetTables: TableInfo[];
sourceDetails: TableSchemaDetail[];
targetDetails: TableSchemaDetail[];
sourceFunctions?: FunctionInfo[];
targetFunctions?: FunctionInfo[];
sourceSequences?: SequenceInfo[];
targetSequences?: SequenceInfo[];
sourceRules?: RuleInfo[];
targetRules?: RuleInfo[];
sourceOwners?: OwnerInfo[];
targetOwners?: OwnerInfo[];
databaseType: DatabaseType;
targetSchema?: string;
ignoreComments?: boolean;
cascadeDelete?: boolean;
}
export interface SchemaDiffPreparation {
diffs: TableDiff[];
functionDiffs?: FunctionDiff[];
sequenceDiffs?: SequenceDiff[];
ruleDiffs?: RuleDiff[];
ownerDiffs?: OwnerDiff[];
syncSql: string;
}
// Unified object type for UI display
export type DiffOperationType = "modify" | "create" | "delete" | "none";
export type DiffObjectKind = "table" | "view" | "function" | "sequence" | "rule" | "owner" | "index" | "trigger" | "foreignKey";
export interface SchemaDiffObject {
id: string;
operationType: DiffOperationType;
objectKind: DiffObjectKind;
name: string;
sourceName?: string;
targetName?: string;
selected: boolean;
sourceDdl?: string;
targetDdl?: string;
deploySql?: string;
changes?: string[];
children?: SchemaDiffObject[];
/** Function arguments signature (for PostgreSQL overloaded functions) */
arguments?: string;
}
export interface SchemaDiffGroup {
operationType: DiffOperationType;
label: string;
count: number;
selectedCount: number;
expanded: boolean;
objects: SchemaDiffObject[];
}
export function getOperationType(diffType: string): DiffOperationType {
switch (diffType) {
case "modified":
return "modify";
case "added":
return "create";
case "removed":
return "delete";
default:
return "none";
}
}
export function getOperationLabel(operationType: DiffOperationType): string {
switch (operationType) {
case "modify":
return "diff.operationLabel.modify";
case "create":
return "diff.operationLabel.create";
case "delete":
return "diff.operationLabel.delete";
case "none":
return "diff.operationLabel.none";
}
}
function buildSequenceDdl(seq: SequenceInfo): string {
const parts = [`CREATE SEQUENCE ${seq.name}`];
if (seq.data_type) parts.push(` AS ${seq.data_type}`);
if (seq.start_value != null) parts.push(` START WITH ${seq.start_value}`);
if (seq.increment != null) parts.push(` INCREMENT BY ${seq.increment}`);
if (seq.min_value != null) parts.push(` MINVALUE ${seq.min_value}`);
if (seq.max_value != null) parts.push(` MAXVALUE ${seq.max_value}`);
else parts.push(` NO MAXVALUE`);
parts.push(` ${seq.cycle ? "" : "NO "}CYCLE`);
parts.push(`;`);
if (seq.last_value != null) {
parts.push(`SELECT setval('${seq.name}', ${seq.last_value});`);
}
return parts.join("\n");
}
export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = []): SchemaDiffObject[] {
const objects: SchemaDiffObject[] = [];
for (const diff of tableDiffs) {
const opType = getOperationType(diff.type);
const obj: SchemaDiffObject = {
id: `table-${diff.name}`,
operationType: opType,
objectKind: diff.objectType === "view" ? "view" : "table",
name: diff.name,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : diff.name,
selected: opType !== "none",
sourceDdl: diff.ddl,
targetDdl: diff.targetDdl,
changes: diff.columns?.flatMap((c) => c.changes || []),
children: [
...(diff.columns?.map((c) => ({
id: `col-${diff.name}-${c.name}`,
operationType: getOperationType(c.type),
objectKind: "table" as DiffObjectKind,
name: c.name,
sourceName: c.type === "added" ? undefined : c.name,
targetName: c.type === "removed" ? undefined : c.name,
selected: opType !== "none",
changes: c.changes,
})) || []),
...(diff.indexes?.map((i) => ({
id: `idx-${diff.name}-${i.name}`,
operationType: getOperationType(i.type),
objectKind: "index" as DiffObjectKind,
name: i.name,
sourceName: i.type === "added" ? undefined : i.name,
targetName: i.type === "removed" ? undefined : i.name,
selected: opType !== "none",
changes: i.changes,
})) || []),
...(diff.foreignKeys?.map((f) => ({
id: `fk-${diff.name}-${f.name}`,
operationType: getOperationType(f.type),
objectKind: "foreignKey" as DiffObjectKind,
name: f.name,
sourceName: f.type === "added" ? undefined : f.name,
targetName: f.type === "removed" ? undefined : f.name,
selected: opType !== "none",
changes: f.changes,
})) || []),
...(diff.triggers?.map((t) => ({
id: `trg-${diff.name}-${t.name}`,
operationType: getOperationType(t.type),
objectKind: "trigger" as DiffObjectKind,
name: t.name,
sourceName: t.type === "added" ? undefined : t.name,
targetName: t.type === "removed" ? undefined : t.name,
selected: opType !== "none",
changes: t.changes,
})) || []),
],
};
objects.push(obj);
}
for (const diff of functionDiffs) {
const args = diff.source?.arguments || diff.target?.arguments || "";
objects.push({
id: `func-${diff.name}-${args}`,
operationType: getOperationType(diff.type),
objectKind: "function",
name: diff.name,
arguments: args,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : diff.name,
selected: true,
sourceDdl: diff.source?.definition,
targetDdl: diff.target?.definition,
changes: diff.changes,
});
}
for (const diff of sequenceDiffs) {
objects.push({
id: `seq-${diff.name}`,
operationType: getOperationType(diff.type),
objectKind: "sequence",
name: diff.name,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : diff.name,
selected: true,
sourceDdl: diff.source ? buildSequenceDdl(diff.source) : undefined,
targetDdl: diff.target ? buildSequenceDdl(diff.target) : undefined,
changes: diff.changes,
});
}
for (const diff of ruleDiffs) {
objects.push({
id: `rule-${diff.name}`,
operationType: getOperationType(diff.type),
objectKind: "rule",
name: diff.name,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : diff.name,
selected: true,
changes: diff.changes,
});
}
for (const diff of ownerDiffs) {
objects.push({
id: `owner-${diff.objectName}`,
operationType: getOperationType(diff.type),
objectKind: "owner",
name: diff.objectName,
sourceName: diff.type === "added" ? undefined : diff.objectName,
targetName: diff.type === "removed" ? undefined : diff.objectName,
selected: true,
changes: diff.changes,
});
}
return objects;
}
export interface ObjectTypeGroup {
kind: DiffObjectKind;
label: string;
objects: SchemaDiffObject[];
expanded: boolean;
selectedCount: number;
}
export interface OperationGroup {
operationType: DiffOperationType;
label: string;
count: number;
selectedCount: number;
expanded: boolean;
typeGroups: ObjectTypeGroup[];
}
export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[] {
const groups: Record<DiffOperationType, Record<DiffObjectKind, SchemaDiffObject[]>> = {
modify: {
table: [],
view: [],
function: [],
sequence: [],
rule: [],
owner: [],
index: [],
foreignKey: [],
trigger: [],
},
create: {
table: [],
view: [],
function: [],
sequence: [],
rule: [],
owner: [],
index: [],
foreignKey: [],
trigger: [],
},
delete: {
table: [],
view: [],
function: [],
sequence: [],
rule: [],
owner: [],
index: [],
foreignKey: [],
trigger: [],
},
none: {
table: [],
view: [],
function: [],
sequence: [],
rule: [],
owner: [],
index: [],
foreignKey: [],
trigger: [],
},
};
for (const obj of objects) {
groups[obj.operationType][obj.objectKind].push(obj);
}
const order: DiffOperationType[] = ["modify", "create", "delete", "none"];
return order.map((opType) => {
const typeGroups: ObjectTypeGroup[] = [];
const kinds: DiffObjectKind[] = ["table", "view", "function", "sequence", "rule", "owner", "index", "foreignKey", "trigger"];
for (const kind of kinds) {
const objs = groups[opType][kind];
if (objs.length > 0) {
typeGroups.push({
kind,
label: getObjectTypeLabel(kind),
objects: objs,
expanded: true,
selectedCount: objs.filter((o) => o.selected).length,
});
}
}
const allObjects = Object.values(groups[opType]).flat();
return {
operationType: opType,
label: getOperationLabel(opType),
count: allObjects.length,
selectedCount: allObjects.filter((o) => o.selected).length,
expanded: opType !== "none",
typeGroups,
};
});
}
function getObjectTypeLabel(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "diff.objectKindLabel.table";
case "view":
return "diff.objectKindLabel.view";
case "function":
return "diff.objectKindLabel.function";
case "sequence":
return "diff.objectKindLabel.sequence";
case "rule":
return "diff.objectKindLabel.rule";
case "owner":
return "diff.objectKindLabel.owner";
case "index":
return "diff.objectKindLabel.index";
case "foreignKey":
return "diff.objectKindLabel.foreignKey";
case "trigger":
return "diff.objectKindLabel.trigger";
default:
return kind;
}
}

View File

@ -0,0 +1,59 @@
import type { SchemaDiffOptionItem, SchemaDiffCompareOptions } from "@/types/schemaDiff";
export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
{
id: "tables",
labelKey: "schemaDiff.options.tables",
defaultChecked: true,
children: [
{ id: "primaryKeys", labelKey: "schemaDiff.options.primaryKeys", defaultChecked: true },
{ id: "foreignKeys", labelKey: "schemaDiff.options.foreignKeys", defaultChecked: true },
{ id: "uniqueKeys", labelKey: "schemaDiff.options.uniqueKeys", defaultChecked: true },
{ id: "checks", labelKey: "schemaDiff.options.checks", defaultChecked: true },
{ id: "exclusions", labelKey: "schemaDiff.options.exclusions", defaultChecked: true },
],
},
{ id: "views", labelKey: "schemaDiff.options.views", defaultChecked: true },
{ id: "functions", labelKey: "schemaDiff.options.functions", defaultChecked: true },
{ id: "indexes", labelKey: "schemaDiff.options.indexes", defaultChecked: true },
{ id: "sequences", labelKey: "schemaDiff.options.sequences", defaultChecked: true },
{ id: "triggers", labelKey: "schemaDiff.options.triggers", defaultChecked: true },
{ id: "rules", labelKey: "schemaDiff.options.rules", defaultChecked: true },
{ id: "owners", labelKey: "schemaDiff.options.owners", defaultChecked: true },
{ id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false },
{ id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true },
];
export const SCHEMA_DIFF_OPTIONS_BY_DB_TYPE: Record<string, SchemaDiffOptionItem[]> = {
postgres: POSTGRES_SCHEMA_DIFF_OPTIONS,
opengauss: POSTGRES_SCHEMA_DIFF_OPTIONS,
// mysql: [...] 后续扩展
// sqlserver: [...] 后续扩展
};
export function getSchemaDiffOptionsForDbType(dbType: string): SchemaDiffOptionItem[] {
return SCHEMA_DIFF_OPTIONS_BY_DB_TYPE[dbType] ?? POSTGRES_SCHEMA_DIFF_OPTIONS;
}
export function getOptionIdsFromTree(items: SchemaDiffOptionItem[]): (keyof SchemaDiffCompareOptions)[] {
const ids: (keyof SchemaDiffCompareOptions)[] = [];
for (const item of items) {
ids.push(item.id);
if (item.children) {
ids.push(...getOptionIdsFromTree(item.children));
}
}
return ids;
}
export function buildDefaultOptionsFromTree(items: SchemaDiffOptionItem[]): SchemaDiffCompareOptions {
const options = {} as SchemaDiffCompareOptions;
for (const item of items) {
options[item.id] = item.defaultChecked as never;
if (item.children) {
const childDefaults = buildDefaultOptionsFromTree(item.children);
Object.assign(options, childDefaults);
}
}
return options;
}

View File

@ -11,6 +11,10 @@ import type {
IndexInfo,
ForeignKeyInfo,
TriggerInfo,
FunctionInfo,
SequenceInfo,
RuleInfo,
OwnerInfo,
QueryResult,
SqlReferenceAnalysis,
DatabaseType,
@ -27,7 +31,7 @@ import type { AiConfig } from "@/stores/settingsStore";
import type { QueryEditability } from "@/lib/sqlAnalysis";
import type { DataGridColumnValueFilterConditionOptions, DataGridContextFilterConditionOptions, DataGridCountSqlOptions, DataGridCopyInsertStatementOptions, DataGridCopyUpdateStatementOptions, DataGridSaveStatementOptions, HiveTablePropertiesSqlOptions } from "@/lib/dataGridSql";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataCompare";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff } from "@/lib/schemaDiff";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schemaDiff";
import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/tableSelectSql";
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/databaseSearch";
@ -750,8 +754,33 @@ export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions):
return invoke("prepare_schema_diff", { options });
}
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string): Promise<string> {
return invoke("generate_schema_sync_sql", { diffs, databaseType, targetSchema });
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string, functionDiffs?: FunctionDiff[], sequenceDiffs?: SequenceDiff[], ruleDiffs?: RuleDiff[], ownerDiffs?: OwnerDiff[], cascadeDelete?: boolean): Promise<string> {
return invoke("generate_schema_sync_sql", {
diffs,
databaseType,
targetSchema,
functionDiffs: functionDiffs ?? [],
sequenceDiffs: sequenceDiffs ?? [],
ruleDiffs: ruleDiffs ?? [],
ownerDiffs: ownerDiffs ?? [],
cascadeDelete: cascadeDelete ?? false,
});
}
export async function listFunctions(connectionId: string, database: string, schema: string): Promise<FunctionInfo[]> {
return invoke("list_functions", { connectionId, database, schema });
}
export async function listSequences(connectionId: string, database: string, schema: string, withLastValues: boolean): Promise<SequenceInfo[]> {
return invoke("list_sequences", { connectionId, database, schema, withLastValues });
}
export async function listRules(connectionId: string, database: string, schema: string): Promise<RuleInfo[]> {
return invoke("list_rules", { connectionId, database, schema });
}
export async function listOwners(connectionId: string, database: string, schema: string): Promise<OwnerInfo[]> {
return invoke("list_owners", { connectionId, database, schema });
}
export async function saveConnections(configs: ConnectionConfig[]): Promise<void> {

View File

@ -213,13 +213,36 @@ export const DEFAULT_CUSTOM_THEME_COLORS: CustomThemeColors = {
builtin: "#f38ba8",
};
export interface CustomThemeDdlColors {
addedRowBg: string;
addedRowBgAlpha: number;
removedRowBg: string;
removedRowBgAlpha: number;
modifiedRowBg: string;
modifiedRowBgAlpha: number;
modifiedCharBg: string;
modifiedCharBgAlpha: number;
}
export const DEFAULT_CUSTOM_THEME_DDL_COLORS: CustomThemeDdlColors = {
addedRowBg: "#22c55e",
addedRowBgAlpha: 10,
removedRowBg: "#ef4444",
removedRowBgAlpha: 10,
modifiedRowBg: "#eab308",
modifiedRowBgAlpha: 10,
modifiedCharBg: "#f59e0b",
modifiedCharBgAlpha: 50,
};
export interface CustomTheme {
id: string;
name: string;
colors: CustomThemeColors;
ddlColors: CustomThemeDdlColors;
}
export const DEFAULT_CUSTOM_THEMES: CustomTheme[] = [{ id: "default", name: "Custom", colors: { ...DEFAULT_CUSTOM_THEME_COLORS } }];
export const DEFAULT_CUSTOM_THEMES: CustomTheme[] = [{ id: "default", name: "Custom", colors: { ...DEFAULT_CUSTOM_THEME_COLORS }, ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS } }];
export interface EditorSettings {
fontFamily: string;
@ -465,7 +488,14 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
},
customThemes: (() => {
if (Array.isArray(settings.customThemes) && settings.customThemes.length > 0) {
return settings.customThemes.map((theme) => (theme.name === "默认" ? { ...theme, name: "Custom" } : theme));
return settings.customThemes.map((theme) => {
const renamed = theme.name === "默认" ? { ...theme, name: "Custom" } : { ...theme };
return {
...renamed,
colors: { ...DEFAULT_CUSTOM_THEME_COLORS, ...renamed.colors },
ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS, ...(renamed as any).ddlColors },
};
});
}
return [
...(settings.customThemeColors
@ -474,10 +504,10 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
id: "migrated",
name: "Migrated",
colors: { ...DEFAULT_CUSTOM_THEME_COLORS, ...settings.customThemeColors },
ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS },
},
]
: []),
...DEFAULT_CUSTOM_THEMES,
];
})(),
activeCustomThemeId: settings.activeCustomThemeId ?? "default",

View File

@ -270,6 +270,37 @@ export interface TriggerInfo {
statement?: string | null;
}
export interface FunctionInfo {
name: string;
function_type: string;
data_type: string;
definition: string;
arguments: string;
}
export interface SequenceInfo {
name: string;
data_type: string;
start_value: string;
min_value: string;
max_value: string;
increment: string;
cycle: boolean;
last_value?: string | null;
}
export interface RuleInfo {
name: string;
table_name: string;
definition: string;
}
export interface OwnerInfo {
object_name: string;
object_type: string;
owner: string;
}
export interface QueryResult {
columns: string[];
/**

View File

@ -0,0 +1,111 @@
export interface SchemaDiffCompareOptions {
tables: boolean;
primaryKeys: boolean;
foreignKeys: boolean;
uniqueKeys: boolean;
checks: boolean;
exclusions: boolean;
views: boolean;
functions: boolean;
indexes: boolean;
sequences: boolean;
triggers: boolean;
rules: boolean;
owners: boolean;
cascadeDelete: boolean;
sequenceLastValues: boolean;
}
export interface SchemaDiffConfig {
id: string;
name: string;
createdAt: number;
updatedAt: number;
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
options: SchemaDiffCompareOptions;
}
export interface SchemaDiffOptionItem {
id: keyof SchemaDiffCompareOptions;
labelKey: string;
defaultChecked: boolean;
children?: SchemaDiffOptionItem[];
}
export type SchemaDiffOptionsMap = Partial<Record<string, SchemaDiffOptionItem[]>>;
export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
tables: true,
primaryKeys: true,
foreignKeys: true,
uniqueKeys: true,
checks: true,
exclusions: true,
views: true,
functions: true,
indexes: true,
sequences: true,
triggers: true,
rules: true,
owners: true,
cascadeDelete: false,
sequenceLastValues: true,
};
export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
tables: true,
primaryKeys: true,
foreignKeys: true,
uniqueKeys: true,
checks: true,
exclusions: false,
views: true,
functions: false,
indexes: true,
sequences: false,
triggers: true,
rules: false,
owners: false,
cascadeDelete: false,
sequenceLastValues: false,
};
export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOptions {
if (dbType === "postgres" || dbType === "opengauss") {
return { ...DEFAULT_POSTGRES_OPTIONS };
}
return { ...DEFAULT_MYSQL_OPTIONS };
}
export function createEmptyConfig(id: string, name: string): SchemaDiffConfig {
const now = Date.now();
return {
id,
name,
createdAt: now,
updatedAt: now,
sourceConnectionId: "",
sourceDatabase: "",
sourceSchema: "",
targetConnectionId: "",
targetDatabase: "",
targetSchema: "",
options: { ...DEFAULT_POSTGRES_OPTIONS },
};
}
export function cloneConfig(config: SchemaDiffConfig, newId: string, newName: string): SchemaDiffConfig {
const now = Date.now();
return {
...config,
id: newId,
name: newName,
createdAt: now,
updatedAt: now,
};
}

View File

@ -1513,7 +1513,7 @@ mod tests {
config.db_type = DatabaseType::Oracle;
config.driver_profile = Some("oracle".to_string());
assert!(should_retry_oracle_with_10g_driver(
assert!(!should_retry_oracle_with_10g_driver(
&config,
"Agent RPC error (-1): ORA-28040: No matching authentication protocol"
));

View File

@ -385,8 +385,7 @@ pub async fn export_database_sql_core(
if request.include_objects && request.selected_tables.is_empty() {
if let Ok(objects) =
crate::schema::list_objects_core(state, &request.connection_id, &request.database, &request.schema, None)
.await
crate::schema::list_objects_core(state, &request.connection_id, &request.database, &request.schema).await
{
for obj in &objects {
let ot = obj.object_type.to_uppercase();

View File

@ -20,7 +20,8 @@ use tokio_postgres::{Row, SimpleQueryMessage};
use super::file_validator::validate_file_path;
use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectInfo, QueryResult, TableInfo, TriggerInfo,
ColumnInfo, DatabaseInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, OwnerInfo, QueryResult, RuleInfo,
SequenceInfo, TableInfo, TriggerInfo,
};
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
@ -488,20 +489,6 @@ fn format_pg_timestamptz(value: DateTime<Local>) -> String {
value.to_rfc3339()
}
/// Render PostgreSQL's internal `"char"` type (OID 18) as the character it
/// stores, matching psql's `charout`. The driver decodes this single-byte type
/// as i8; emitting the numeric value would leak the raw ASCII code (issue #669).
/// A zero byte maps to an empty string; any other byte is interpreted as a
/// Latin-1 code point so the result is always valid UTF-8 and never panics.
fn pg_char_to_json(byte: i8) -> serde_json::Value {
let b = byte as u8;
if b == 0 {
serde_json::Value::String(String::new())
} else {
serde_json::Value::String(char::from(b).to_string())
}
}
fn pg_value_to_json(row: &Row, idx: usize, type_name: &str) -> serde_json::Value {
let upper = type_name.to_uppercase();
@ -555,14 +542,6 @@ fn pg_value_to_json(row: &Row, idx: usize, type_name: &str) -> serde_json::Value
return pg_system_u32_to_json(row, idx).unwrap_or(serde_json::Value::Null);
}
// PostgreSQL's internal "char" type (OID 18, e.g. pg_depend.deptype) is a
// single byte the driver decodes as i8. Without this branch it falls through
// to the i8 arm below and surfaces the raw ASCII code (110 for 'n') instead
// of the character. SQL CHAR(n) is a different type ("bpchar"), unaffected.
if upper == "CHAR" {
return row.try_get::<_, i8>(idx).map(pg_char_to_json).unwrap_or(serde_json::Value::Null);
}
if upper.starts_with('_') {
return pg_array_to_json_value(row, idx).unwrap_or(serde_json::Value::Null);
}
@ -686,14 +665,14 @@ async fn execute_select_prepared(
Ok(QueryResult {
columns,
column_sortables: vec![],
column_types: Vec::new(),
column_sortables: Vec::new(),
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
session_id: None,
has_more: false,
column_types,
})
}
@ -737,14 +716,14 @@ async fn execute_select_text(
Ok(QueryResult {
columns,
column_sortables: vec![],
column_types: Vec::new(),
column_sortables: Vec::new(),
rows: result_rows,
affected_rows: 0,
execution_time_ms: start.elapsed().as_millis(),
truncated,
session_id: None,
has_more: false,
column_types: Vec::new(),
})
}
@ -785,7 +764,7 @@ pub async fn connect(url: &str, fallback_timeout: Duration) -> Result<Pool, Stri
mgr_config,
);
let pool = Pool::builder(mgr)
.max_size(1)
.max_size(4)
.runtime(Runtime::Tokio1)
.wait_timeout(Some(timeout))
.build()
@ -1114,7 +1093,6 @@ pub async fn list_databases(pool: &Pool) -> Result<Vec<DatabaseInfo>, String> {
}
pub async fn list_tables(pool: &Pool, schema: &str) -> Result<Vec<TableInfo>, String> {
let schema = if schema.is_empty() { "public" } else { schema };
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client.prepare_cached(postgres_tables_sql()).await.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
@ -1154,7 +1132,6 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str {
CASE c.relkind \
WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'VIEW' \
WHEN 'S' THEN 'SEQUENCE' \
ELSE 'TABLE' \
END AS object_type, \
obj_description(c.oid) AS object_comment, \
@ -1166,7 +1143,7 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str {
) AS updated_at, \
CASE WHEN pc.relkind = 'p' THEN pn.nspname ELSE NULL END AS parent_schema, \
CASE WHEN pc.relkind = 'p' THEN pc.relname ELSE NULL END AS parent_name, \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 WHEN 'S' THEN 4 ELSE 0 END AS sort_order \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \
@ -1175,7 +1152,7 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str {
LEFT JOIN LATERAL pg_stat_file( \
CASE WHEN c.relkind IN ('r','m','f','p') THEN pg_relation_filepath(c.oid) END, true \
) stat ON true \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p','S') \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
UNION ALL \
SELECT p.proname AS object_name, \
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
@ -1196,7 +1173,6 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str {
CASE c.relkind \
WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'VIEW' \
WHEN 'S' THEN 'SEQUENCE' \
ELSE 'TABLE' \
END AS object_type, \
obj_description(c.oid) AS object_comment, \
@ -1204,13 +1180,13 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str {
NULL::text AS updated_at, \
CASE WHEN pc.relkind = 'p' THEN pn.nspname ELSE NULL END AS parent_schema, \
CASE WHEN pc.relkind = 'p' THEN pc.relname ELSE NULL END AS parent_name, \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 WHEN 'S' THEN 4 ELSE 0 END AS sort_order \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \
LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \
LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p','S') \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
UNION ALL \
SELECT p.proname AS object_name, \
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
@ -1226,112 +1202,15 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str {
ORDER BY sort_order, object_name"
}
fn list_objects_legacy_routines_sql(include_timestamps: bool) -> &'static str {
if include_timestamps {
return "SELECT c.relname AS object_name, \
CASE c.relkind \
WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'VIEW' \
WHEN 'S' THEN 'SEQUENCE' \
ELSE 'TABLE' \
END AS object_type, \
obj_description(c.oid) AS object_comment, \
stat.creation::text AS created_at, \
COALESCE( \
CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \
THEN pg_xact_commit_timestamp(c.xmin)::text END, \
stat.modification::text \
) AS updated_at, \
CASE WHEN pc.relkind = 'p' THEN pn.nspname ELSE NULL END AS parent_schema, \
CASE WHEN pc.relkind = 'p' THEN pc.relname ELSE NULL END AS parent_name, \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 WHEN 'S' THEN 4 ELSE 0 END AS sort_order \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \
LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \
LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \
LEFT JOIN LATERAL pg_stat_file( \
CASE WHEN c.relkind IN ('r','m','f','p') THEN pg_relation_filepath(c.oid) END, true \
) stat ON true \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p','S') \
UNION ALL \
SELECT p.proname AS object_name, \
CASE WHEN EXISTS ( \
SELECT 1 FROM information_schema.routines r \
WHERE r.specific_schema = n.nspname \
AND r.routine_name = p.proname \
AND upper(r.routine_type) = 'PROCEDURE' \
) THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
obj_description(p.oid) AS object_comment, \
NULL::text AS created_at, \
CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \
THEN pg_xact_commit_timestamp(p.xmin)::text END AS updated_at, \
NULL::text AS parent_schema, \
NULL::text AS parent_name, \
CASE WHEN EXISTS ( \
SELECT 1 FROM information_schema.routines r \
WHERE r.specific_schema = n.nspname \
AND r.routine_name = p.proname \
AND upper(r.routine_type) = 'PROCEDURE' \
) THEN 2 ELSE 3 END AS sort_order \
FROM pg_catalog.pg_proc p \
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = $1 AND NOT p.proisagg AND NOT p.proiswindow \
ORDER BY sort_order, object_name";
}
"SELECT c.relname AS object_name, \
CASE c.relkind \
WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'VIEW' \
WHEN 'S' THEN 'SEQUENCE' \
ELSE 'TABLE' \
END AS object_type, \
obj_description(c.oid) AS object_comment, \
NULL::text AS created_at, \
NULL::text AS updated_at, \
CASE WHEN pc.relkind = 'p' THEN pn.nspname ELSE NULL END AS parent_schema, \
CASE WHEN pc.relkind = 'p' THEN pc.relname ELSE NULL END AS parent_name, \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 WHEN 'S' THEN 4 ELSE 0 END AS sort_order \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \
LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \
LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p','S') \
UNION ALL \
SELECT p.proname AS object_name, \
CASE WHEN EXISTS ( \
SELECT 1 FROM information_schema.routines r \
WHERE r.specific_schema = n.nspname \
AND r.routine_name = p.proname \
AND upper(r.routine_type) = 'PROCEDURE' \
) THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
obj_description(p.oid) AS object_comment, \
NULL::text AS created_at, \
NULL::text AS updated_at, \
NULL::text AS parent_schema, \
NULL::text AS parent_name, \
CASE WHEN EXISTS ( \
SELECT 1 FROM information_schema.routines r \
WHERE r.specific_schema = n.nspname \
AND r.routine_name = p.proname \
AND upper(r.routine_type) = 'PROCEDURE' \
) THEN 2 ELSE 3 END AS sort_order \
FROM pg_catalog.pg_proc p \
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = $1 AND NOT p.proisagg AND NOT p.proiswindow \
ORDER BY sort_order, object_name"
}
pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
let rows = match client.prepare_cached(list_objects_sql(true)).await {
Ok(stmt) => match client.query(&stmt, &[&schema]).await {
Ok(rows) => rows,
Err(_) => query_list_objects_fallbacks(&client, schema).await?,
},
Err(_) => query_list_objects_fallbacks(&client, schema).await?,
let stmt = client.prepare_cached(list_objects_sql(true)).await.map_err(|e| e.to_string())?;
let rows = match client.query(&stmt, &[&schema]).await {
Ok(rows) => rows,
Err(_) => {
let stmt = client.prepare_cached(list_objects_sql(false)).await.map_err(|e| e.to_string())?;
client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?
}
};
Ok(rows
@ -1349,22 +1228,6 @@ pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>,
.collect())
}
async fn query_list_objects_fallbacks(
client: &deadpool_postgres::Object,
schema: &str,
) -> Result<Vec<tokio_postgres::Row>, String> {
for sql in
[list_objects_sql(false), list_objects_legacy_routines_sql(true), list_objects_legacy_routines_sql(false)]
{
if let Ok(stmt) = client.prepare_cached(sql).await {
if let Ok(rows) = client.query(&stmt, &[&schema]).await {
return Ok(rows);
}
}
}
Err("failed to list PostgreSQL objects with compatible metadata queries".to_string())
}
pub async fn list_schemas(pool: &Pool) -> Result<Vec<String>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client
@ -1493,7 +1356,6 @@ async fn get_columns_with_sql(
}
pub async fn get_columns(pool: &Pool, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let schema = if schema.is_empty() { "public" } else { schema };
let client = pool.get().await.map_err(|e| e.to_string())?;
match get_columns_with_sql(&client, POSTGRES_COLUMNS_SQL, schema, table).await {
Ok(columns) => Ok(columns),
@ -1550,14 +1412,14 @@ pub async fn execute_query_with_max_rows(
Ok(QueryResult {
columns: vec![],
column_sortables: vec![],
column_types: Vec::new(),
column_sortables: Vec::new(),
rows: vec![],
affected_rows: affected,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
column_types: Vec::new(),
})
}
}
@ -1640,14 +1502,14 @@ async fn execute_query_with_max_rows_inner(
Ok(QueryResult {
columns: vec![],
column_sortables: vec![],
column_types: Vec::new(),
column_sortables: Vec::new(),
rows: vec![],
affected_rows: affected,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
column_types: Vec::new(),
})
}
}
@ -1692,28 +1554,6 @@ const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indkey \
ORDER BY i.relname";
const POSTGRES_INDEXES_OPENGAUSS_SQL: &str = "SELECT i.relname AS index_name, \
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \
ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \
NULL::smallint AS nkeyatts, \
ix.indkey AS indkey, \
obj_description(i.oid, 'pg_class') AS index_comment \
FROM pg_index ix \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN pg_am am ON am.oid = i.relam \
JOIN LATERAL ( \
SELECT unnest(ix.indkey) AS attnum, generate_series(1, array_length(ix.indkey, 1)) AS n \
) AS k ON true \
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
WHERE n.nspname = $1 AND t.relname = $2 \
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indkey \
ORDER BY i.relname";
async fn list_indexes_with_sql(
client: &deadpool_postgres::Client,
sql: &str,
@ -1752,21 +1592,14 @@ pub async fn list_indexes(pool: &Pool, schema: &str, table: &str) -> Result<Vec<
Err(primary_error) => match list_indexes_with_sql(&client, POSTGRES_INDEXES_COMPAT_SQL, schema, table).await {
Ok(indexes) => Ok(indexes),
Err(fallback_error) => {
match list_indexes_with_sql(&client, POSTGRES_INDEXES_OPENGAUSS_SQL, schema, table).await {
Ok(indexes) => Ok(indexes),
Err(opengauss_error) => {
let primary_message = pg_error_to_string(primary_error);
let fallback_message = pg_error_to_string(fallback_error);
let opengauss_message = pg_error_to_string(opengauss_error);
log::debug!(
"[postgres][list_indexes:opengauss-failed] primary_error={} fallback_error={} opengauss_error={}",
primary_message,
fallback_message,
opengauss_message
);
Err(opengauss_message)
}
}
let primary_message = pg_error_to_string(primary_error);
let fallback_message = pg_error_to_string(fallback_error);
log::debug!(
"[postgres][list_indexes:compat-failed] primary_error={} fallback_error={}",
primary_message,
fallback_message
);
Err(fallback_message)
}
},
}
@ -1837,6 +1670,170 @@ pub async fn list_triggers(pool: &Pool, schema: &str, table: &str) -> Result<Vec
.collect())
}
pub async fn list_functions(pool: &Pool, schema: &str) -> Result<Vec<FunctionInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
// Use pg_proc + pg_get_functiondef() instead of information_schema.routines
// for reliable function definition retrieval (information_schema.routines.routine_definition
// is NULL for non-SQL functions like plpgsql)
let stmt = client
.prepare_cached(
"SELECT p.proname, \
CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, \
COALESCE(pg_get_function_result(p.oid), ''), \
pg_get_functiondef(p.oid), \
COALESCE(pg_get_function_arguments(p.oid), '') \
FROM pg_proc p \
JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = $1 AND p.prokind IN ('f', 'p') \
ORDER BY p.proname",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let def: String = row.get::<_, String>(3);
// Remove schema qualification from CREATE FUNCTION statement
// to avoid false differences when comparing across schemas.
// Handle both "schema.name" and schema.name formats.
let normalized_def = def
.replace(&format!("CREATE OR REPLACE FUNCTION \"{}\".", schema), "CREATE OR REPLACE FUNCTION ")
.replace(&format!("CREATE OR REPLACE FUNCTION {}.", schema), "CREATE OR REPLACE FUNCTION ");
FunctionInfo {
name: row.get::<_, String>(0),
function_type: row.get::<_, String>(1),
data_type: row.get::<_, String>(2),
definition: normalized_def,
arguments: row.get::<_, String>(4),
}
})
.collect())
}
pub async fn list_sequences(pool: &Pool, schema: &str, with_last_values: bool) -> Result<Vec<SequenceInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
// Use pg_class + pg_sequence + pg_namespace instead of pg_sequences view
// for better compatibility and permission handling
let stmt = client
.prepare_cached(
"SELECT c.relname, \
COALESCE(format_type(s.seqtypid, NULL), 'bigint'), \
COALESCE(s.seqstart::text, '1'), \
COALESCE(s.seqmin::text, '1'), \
COALESCE(s.seqmax::text, '9223372036854775807'), \
COALESCE(s.seqincrement::text, '1'), \
CASE WHEN s.seqcycle THEN 'YES' ELSE 'NO' END \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_sequence s ON s.seqrelid = c.oid \
WHERE c.relkind = 'S' AND n.nspname = $1 \
ORDER BY c.relname",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
let mut sequences: Vec<SequenceInfo> = rows
.iter()
.map(|row| SequenceInfo {
name: row.get::<_, String>(0),
data_type: row.get::<_, String>(1),
start_value: row.get::<_, String>(2),
min_value: row.get::<_, String>(3),
max_value: row.get::<_, String>(4),
increment: row.get::<_, String>(5),
cycle: row.get::<_, String>(6) == "YES",
last_value: None,
})
.collect();
if with_last_values {
// Batch query: get last values for all sequences in one query
let sql = "SELECT c.relname, pg_sequence_last_value(c.oid) \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind = 'S' AND n.nspname = $1";
if let Ok(stmt) = client.prepare_cached(sql).await {
if let Ok(rows) = client.query(&stmt, &[&schema]).await {
for row in rows {
let name: String = row.get(0);
if let Ok(val) = row.try_get::<_, i64>(1) {
if let Some(seq) = sequences.iter_mut().find(|s| s.name == name) {
seq.last_value = Some(val.to_string());
}
}
}
}
}
}
Ok(sequences)
}
pub async fn list_rules(pool: &Pool, schema: &str) -> Result<Vec<RuleInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
let stmt = client
.prepare_cached(
"SELECT schemaname, tablename, rulename, definition \
FROM pg_rules \
WHERE schemaname = $1 \
ORDER BY rulename",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| RuleInfo {
name: row.get::<_, String>(2),
table_name: row.get::<_, String>(1),
definition: row.get::<_, String>(3),
})
.collect())
}
pub async fn list_owners(pool: &Pool, schema: &str) -> Result<Vec<OwnerInfo>, String> {
let client = pool.get().await.map_err(|e| e.to_string())?;
// Filter relkind to exclude indexes, toast tables, and other system objects
// for better performance on large databases
let stmt = client
.prepare_cached(
"SELECT n.nspname, c.relname, c.relkind, pg_get_userbyid(c.relowner) \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = $1 \
AND c.relkind IN ('r', 'v', 'm', 'S', 'f', 'p')",
)
.await
.map_err(|e| e.to_string())?;
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| {
let relkind: String = row.get(2);
let object_type = match relkind.as_str() {
"r" => "TABLE",
"v" => "VIEW",
"m" => "MATERIALIZED VIEW",
"S" => "SEQUENCE",
"f" => "FOREIGN TABLE",
"p" => "PARTITIONED TABLE",
"I" => "PARTITIONED INDEX",
_ => &relkind,
};
OwnerInfo {
object_name: row.get::<_, String>(1),
object_type: object_type.to_string(),
owner: row.get::<_, String>(3),
}
})
.collect())
}
/// Execute multiple SQL statements in a single round-trip using batch_execute.
/// Best for DDL scripts where per-statement affected-row counts are not needed.
pub async fn execute_batch(pool: &Pool, statements: &[String]) -> Result<(), String> {
@ -1914,25 +1911,6 @@ mod tests {
assert!(!PgSystemU32::accepts(&Type::INT4));
}
#[test]
fn pg_char_type_renders_byte_as_character() {
// The internal "char" type (OID 18, e.g. pg_depend.deptype) is decoded
// as i8; we must surface the character, not the ASCII code (issue #669).
assert_eq!(Type::CHAR.name(), "char");
assert!(i8::accepts(&Type::CHAR));
// SQL CHAR(n)/character(n) is a different type ("bpchar") and must not
// be routed through the "char" branch.
assert_eq!(Type::BPCHAR.name(), "bpchar");
assert_eq!(pg_char_to_json(b'n' as i8), serde_json::Value::String("n".into()));
assert_eq!(pg_char_to_json(b'a' as i8), serde_json::Value::String("a".into()));
assert_eq!(pg_char_to_json(b'i' as i8), serde_json::Value::String("i".into()));
// A zero byte renders as an empty string (matches psql's charout).
assert_eq!(pg_char_to_json(0), serde_json::Value::String(String::new()));
// High bytes stay valid UTF-8 (Latin-1) and never panic.
assert_eq!(pg_char_to_json(-1), serde_json::Value::String("\u{00ff}".into()));
}
#[test]
fn pg_any_string_accepts_all_types_and_decodes_utf8() {
// Accepts any type — built-in, custom enum OIDs, domains, etc.
@ -2034,7 +2012,7 @@ mod tests {
let escaped = pg_quote_ident(malicious);
// Double quotes should be doubled, not breaking out
assert_eq!(escaped, r#""public""; DROP TABLE users; --""#);
assert!(escaped.matches('"').count().is_multiple_of(2), "quote count should be even");
assert!(escaped.matches('"').count() % 2 == 0, "quote count should be even");
}
// --- query_result_row_limit ---
@ -2250,14 +2228,6 @@ mod tests {
assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("NULL::smallint AS nkeyatts"));
}
#[test]
fn postgres_index_metadata_has_opengauss_compatible_fallback() {
assert!(!POSTGRES_INDEXES_OPENGAUSS_SQL.contains("WITH ORDINALITY"));
assert!(POSTGRES_INDEXES_OPENGAUSS_SQL.contains("generate_series"));
assert!(POSTGRES_INDEXES_OPENGAUSS_SQL.contains("array_length"));
assert!(POSTGRES_INDEXES_OPENGAUSS_SQL.contains("NULL::smallint AS nkeyatts"));
}
#[test]
fn list_objects_sql_includes_routines() {
let sql = list_objects_sql(true);
@ -2271,8 +2241,6 @@ mod tests {
assert!(sql.contains("pg_xact_commit_timestamp"));
assert!(sql.contains("'PROCEDURE'"));
assert!(sql.contains("'FUNCTION'"));
assert!(sql.contains("'SEQUENCE'"));
assert!(sql.contains("'S'"));
}
#[test]
@ -2295,20 +2263,6 @@ mod tests {
assert!(list_objects_sql(false).contains("pg_catalog.pg_proc"));
}
#[test]
fn legacy_list_objects_sql_avoids_pg11_prokind() {
let sql = list_objects_legacy_routines_sql(false);
assert!(sql.contains("pg_catalog.pg_proc"));
assert!(sql.contains("information_schema.routines"));
assert!(sql.contains("'PROCEDURE'"));
assert!(sql.contains("'FUNCTION'"));
assert!(sql.contains("NOT p.proisagg"));
assert!(sql.contains("NOT p.proiswindow"));
assert!(!sql.contains("p.prokind"));
assert!(sql.contains("'SEQUENCE'"));
}
#[test]
fn transaction_recovery_statement_detection_matches_common_postgres_commands() {
assert!(is_transaction_recovery_statement("ROLLBACK"));
@ -2359,14 +2313,14 @@ mod tests {
#[tokio::test]
async fn execute_batch_whitespace_only_is_filtered() {
let statements = [" ".to_string(), "\t\n".to_string(), "".to_string()];
let statements = vec![" ".to_string(), "\t\n".to_string(), "".to_string()];
let combined = statements.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(";\n");
assert!(combined.is_empty());
}
#[test]
fn execute_batch_joins_with_semicolons() {
let statements = ["SELECT 1".to_string(), "SELECT 2".to_string()];
let statements = vec!["SELECT 1".to_string(), "SELECT 2".to_string()];
let combined = statements.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(";\n");
assert_eq!(combined, "SELECT 1;\nSELECT 2");
}

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,9 @@ use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo, TableInfo, TriggerInfo};
use crate::types::{
ColumnInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, OwnerInfo, RuleInfo, SequenceInfo, TableInfo, TriggerInfo,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -61,6 +63,62 @@ pub struct TriggerDiff {
pub changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FunctionDiff {
#[serde(rename = "type")]
pub diff_type: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<FunctionInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<FunctionInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SequenceDiff {
#[serde(rename = "type")]
pub diff_type: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<SequenceInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<SequenceInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuleDiff {
#[serde(rename = "type")]
pub diff_type: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<RuleInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<RuleInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OwnerDiff {
#[serde(rename = "type")]
pub diff_type: String,
pub object_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<OwnerInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<OwnerInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableDiff {
@ -80,6 +138,8 @@ pub struct TableDiff {
#[serde(skip_serializing_if = "Option::is_none")]
pub ddl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_ddl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_table_comment: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_table_comment: Option<Option<String>>,
@ -112,24 +172,63 @@ pub struct SchemaDiffPreparationOptions {
pub source_details: Vec<TableSchemaDetail>,
#[serde(default)]
pub target_details: Vec<TableSchemaDetail>,
#[serde(default)]
pub source_functions: Vec<FunctionInfo>,
#[serde(default)]
pub target_functions: Vec<FunctionInfo>,
#[serde(default)]
pub source_sequences: Vec<SequenceInfo>,
#[serde(default)]
pub target_sequences: Vec<SequenceInfo>,
#[serde(default)]
pub source_rules: Vec<RuleInfo>,
#[serde(default)]
pub target_rules: Vec<RuleInfo>,
#[serde(default)]
pub source_owners: Vec<OwnerInfo>,
#[serde(default)]
pub target_owners: Vec<OwnerInfo>,
pub database_type: DatabaseType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_schema: Option<String>,
#[serde(default)]
pub ignore_comments: bool,
#[serde(default)]
pub cascade_delete: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SchemaDiffPreparation {
pub diffs: Vec<TableDiff>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub function_diffs: Vec<FunctionDiff>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sequence_diffs: Vec<SequenceDiff>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rule_diffs: Vec<RuleDiff>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub owner_diffs: Vec<OwnerDiff>,
pub sync_sql: String,
}
pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffPreparation {
let diffs = diff_schema(&options);
let sync_sql = generate_schema_sync_sql(&diffs, options.database_type, options.target_schema.as_deref());
SchemaDiffPreparation { diffs, sync_sql }
let function_diffs = diff_functions(&options.source_functions, &options.target_functions);
let sequence_diffs = diff_sequences(&options.source_sequences, &options.target_sequences);
let rule_diffs = diff_rules(&options.source_rules, &options.target_rules);
let owner_diffs = diff_owners(&options.source_owners, &options.target_owners);
let sync_sql = generate_schema_sync_sql(
&diffs,
&function_diffs,
&sequence_diffs,
&rule_diffs,
&owner_diffs,
options.database_type,
options.target_schema.as_deref(),
options.cascade_delete,
);
SchemaDiffPreparation { diffs, function_diffs, sequence_diffs, rule_diffs, owner_diffs, sync_sql }
}
fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
@ -176,6 +275,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
diff_type: "added".to_string(),
object_type: Some("table".to_string()),
ddl: source_details.get(name.as_str()).and_then(|detail| detail.ddl.clone()),
target_ddl: None,
name,
columns: None,
indexes: None,
@ -187,6 +287,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
}
for name in removed {
let name_clone = name.clone();
result.push(TableDiff {
diff_type: "removed".to_string(),
object_type: Some("table".to_string()),
@ -196,12 +297,14 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
foreign_keys: None,
triggers: None,
ddl: None,
target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()),
source_table_comment: None,
target_table_comment: None,
});
}
for name in added_views {
let name_clone = name.clone();
result.push(TableDiff {
diff_type: "added".to_string(),
object_type: Some("view".to_string()),
@ -210,13 +313,15 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
indexes: None,
foreign_keys: None,
triggers: None,
ddl: None,
ddl: source_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()),
target_ddl: None,
source_table_comment: None,
target_table_comment: None,
});
}
for name in removed_views {
let name_clone = name.clone();
result.push(TableDiff {
diff_type: "removed".to_string(),
object_type: Some("view".to_string()),
@ -226,6 +331,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
foreign_keys: None,
triggers: None,
ddl: None,
target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()),
source_table_comment: None,
target_table_comment: None,
});
@ -243,27 +349,29 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
let comment_changed = !options.ignore_comments
&& source_comment.clone().unwrap_or_default() != target_comment.clone().unwrap_or_default();
if !column_diffs.is_empty()
let has_diff = !column_diffs.is_empty()
|| !index_diffs.is_empty()
|| !foreign_key_diffs.is_empty()
|| !trigger_diffs.is_empty()
|| comment_changed
{
result.push(TableDiff {
diff_type: "modified".to_string(),
object_type: Some("table".to_string()),
name,
columns: (!column_diffs.is_empty()).then_some(column_diffs),
indexes: (!index_diffs.is_empty()).then_some(index_diffs),
foreign_keys: (!foreign_key_diffs.is_empty()).then_some(foreign_key_diffs),
triggers: (!trigger_diffs.is_empty()).then_some(trigger_diffs),
ddl: None,
source_table_comment: comment_changed.then_some(source_comment),
target_table_comment: comment_changed.then_some(target_comment),
});
}
|| comment_changed;
let name_clone = name.clone();
result.push(TableDiff {
diff_type: if has_diff { "modified".to_string() } else { "none".to_string() },
object_type: Some("table".to_string()),
name,
columns: if has_diff { (!column_diffs.is_empty()).then_some(column_diffs) } else { None },
indexes: if has_diff { (!index_diffs.is_empty()).then_some(index_diffs) } else { None },
foreign_keys: if has_diff { (!foreign_key_diffs.is_empty()).then_some(foreign_key_diffs) } else { None },
triggers: if has_diff { (!trigger_diffs.is_empty()).then_some(trigger_diffs) } else { None },
ddl: source_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()),
target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()),
source_table_comment: if has_diff { comment_changed.then_some(source_comment) } else { None },
target_table_comment: if has_diff { comment_changed.then_some(target_comment) } else { None },
});
}
result.retain(|diff| diff.diff_type != "none");
result
}
@ -563,6 +671,220 @@ fn is_mysql_like(db_type: DatabaseType) -> bool {
)
}
/// Normalize a function definition for comparison by:
/// - Converting CRLF to LF
/// - Collapsing all whitespace (tabs, multiple spaces) to single spaces
/// - Trimming each line and rejoining
fn normalize_definition(def: &str) -> String {
def.replace("\r\n", "\n")
.split('\n')
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n")
}
pub fn diff_functions(source: &[FunctionInfo], target: &[FunctionInfo]) -> Vec<FunctionDiff> {
let mut diffs = Vec::new();
// Use (name, arguments) as key to support PostgreSQL function overloading
let target_map: HashMap<(&str, &str), &FunctionInfo> =
target.iter().map(|f| ((f.name.as_str(), f.arguments.as_str()), f)).collect();
let source_map: HashMap<(&str, &str), &FunctionInfo> =
source.iter().map(|f| ((f.name.as_str(), f.arguments.as_str()), f)).collect();
for source_fn in source {
let key = (source_fn.name.as_str(), source_fn.arguments.as_str());
let Some(target_fn) = target_map.get(&key) else {
diffs.push(FunctionDiff {
diff_type: "added".to_string(),
name: source_fn.name.clone(),
source: Some(source_fn.clone()),
target: None,
changes: Vec::new(),
});
continue;
};
let mut changes = Vec::new();
if source_fn.function_type != target_fn.function_type {
changes.push(format!("type: {}{}", target_fn.function_type, source_fn.function_type));
}
if source_fn.data_type != target_fn.data_type {
changes.push(format!("return type: {}{}", target_fn.data_type, source_fn.data_type));
}
if normalize_definition(&source_fn.definition) != normalize_definition(&target_fn.definition) {
changes.push("definition changed".to_string());
}
if !changes.is_empty() {
diffs.push(FunctionDiff {
diff_type: "modified".to_string(),
name: source_fn.name.clone(),
source: Some(source_fn.clone()),
target: Some((*target_fn).clone()),
changes,
});
}
}
for target_fn in target {
let key = (target_fn.name.as_str(), target_fn.arguments.as_str());
if !source_map.contains_key(&key) {
diffs.push(FunctionDiff {
diff_type: "removed".to_string(),
name: target_fn.name.clone(),
source: None,
target: Some(target_fn.clone()),
changes: Vec::new(),
});
}
}
diffs
}
pub fn diff_sequences(source: &[SequenceInfo], target: &[SequenceInfo]) -> Vec<SequenceDiff> {
let mut diffs = Vec::new();
let target_map: HashMap<&str, &SequenceInfo> = target.iter().map(|s| (s.name.as_str(), s)).collect();
let source_map: HashMap<&str, &SequenceInfo> = source.iter().map(|s| (s.name.as_str(), s)).collect();
for source_seq in source {
let Some(target_seq) = target_map.get(source_seq.name.as_str()) else {
diffs.push(SequenceDiff {
diff_type: "added".to_string(),
name: source_seq.name.clone(),
source: Some(source_seq.clone()),
target: None,
changes: Vec::new(),
});
continue;
};
let mut changes = Vec::new();
if source_seq.data_type != target_seq.data_type {
changes.push(format!("data_type: {}{}", target_seq.data_type, source_seq.data_type));
}
if source_seq.start_value != target_seq.start_value {
changes.push(format!("start: {}{}", target_seq.start_value, source_seq.start_value));
}
if source_seq.min_value != target_seq.min_value {
changes.push(format!("min: {}{}", target_seq.min_value, source_seq.min_value));
}
if source_seq.max_value != target_seq.max_value {
changes.push(format!("max: {}{}", target_seq.max_value, source_seq.max_value));
}
if source_seq.increment != target_seq.increment {
changes.push(format!("increment: {}{}", target_seq.increment, source_seq.increment));
}
if source_seq.cycle != target_seq.cycle {
changes.push(format!("cycle: {}{}", target_seq.cycle, source_seq.cycle));
}
// Only compare last_value when both sides successfully retrieved it.
// Avoid false positives when one side lacks permission (returns None).
if let (Some(s), Some(t)) = (&source_seq.last_value, &target_seq.last_value) {
if s != t {
changes.push(format!("last_value: {}{}", t, s));
}
}
if !changes.is_empty() {
diffs.push(SequenceDiff {
diff_type: "modified".to_string(),
name: source_seq.name.clone(),
source: Some(source_seq.clone()),
target: Some((*target_seq).clone()),
changes,
});
}
}
for target_seq in target {
if !source_map.contains_key(target_seq.name.as_str()) {
diffs.push(SequenceDiff {
diff_type: "removed".to_string(),
name: target_seq.name.clone(),
source: None,
target: Some(target_seq.clone()),
changes: Vec::new(),
});
}
}
diffs
}
pub fn diff_rules(source: &[RuleInfo], target: &[RuleInfo]) -> Vec<RuleDiff> {
let mut diffs = Vec::new();
let target_map: HashMap<&str, &RuleInfo> = target.iter().map(|r| (r.name.as_str(), r)).collect();
let source_map: HashMap<&str, &RuleInfo> = source.iter().map(|r| (r.name.as_str(), r)).collect();
for source_rule in source {
let Some(target_rule) = target_map.get(source_rule.name.as_str()) else {
diffs.push(RuleDiff {
diff_type: "added".to_string(),
name: source_rule.name.clone(),
source: Some(source_rule.clone()),
target: None,
changes: Vec::new(),
});
continue;
};
let mut changes = Vec::new();
if source_rule.definition != target_rule.definition {
changes.push("definition changed".to_string());
}
if !changes.is_empty() {
diffs.push(RuleDiff {
diff_type: "modified".to_string(),
name: source_rule.name.clone(),
source: Some(source_rule.clone()),
target: Some((*target_rule).clone()),
changes,
});
}
}
for target_rule in target {
if !source_map.contains_key(target_rule.name.as_str()) {
diffs.push(RuleDiff {
diff_type: "removed".to_string(),
name: target_rule.name.clone(),
source: None,
target: Some(target_rule.clone()),
changes: Vec::new(),
});
}
}
diffs
}
pub fn diff_owners(source: &[OwnerInfo], target: &[OwnerInfo]) -> Vec<OwnerDiff> {
let mut diffs = Vec::new();
let target_map: HashMap<&str, &OwnerInfo> = target.iter().map(|o| (o.object_name.as_str(), o)).collect();
let _source_map: HashMap<&str, &OwnerInfo> = source.iter().map(|o| (o.object_name.as_str(), o)).collect();
for source_owner in source {
let Some(target_owner) = target_map.get(source_owner.object_name.as_str()) else {
continue; // skip added/removed objects, only compare owners for common objects
};
let mut changes = Vec::new();
if source_owner.owner != target_owner.owner {
changes.push(format!("owner: {}{}", target_owner.owner, source_owner.owner));
}
if !changes.is_empty() {
diffs.push(OwnerDiff {
diff_type: "modified".to_string(),
object_name: source_owner.object_name.clone(),
source: Some(source_owner.clone()),
target: Some((*target_owner).clone()),
changes,
});
}
}
diffs
}
fn quote_id(name: &str, db_type: DatabaseType) -> String {
if is_mysql_like(db_type) {
format!("`{}`", name.replace('`', "``"))
@ -673,9 +995,9 @@ fn add_foreign_key_sql(table_name: &str, fk: &ForeignKeyInfo, db_type: DatabaseT
)
}
fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>) -> String {
fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>, cascade: &str) -> String {
let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" };
format!("DROP {object_type} IF EXISTS {};", qualified_name(&diff.name, db_type, schema))
format!("DROP {object_type} IF EXISTS {}{cascade};", qualified_name(&diff.name, db_type, schema))
}
fn comment_literal(comment: &str) -> String {
@ -707,9 +1029,19 @@ fn table_comment_sql(table_name: &str, comment: &str, db_type: DatabaseType, sch
}
}
pub fn generate_schema_sync_sql(diffs: &[TableDiff], db_type: DatabaseType, schema: Option<&str>) -> String {
pub fn generate_schema_sync_sql(
diffs: &[TableDiff],
function_diffs: &[FunctionDiff],
sequence_diffs: &[SequenceDiff],
rule_diffs: &[RuleDiff],
owner_diffs: &[OwnerDiff],
db_type: DatabaseType,
schema: Option<&str>,
cascade_delete: bool,
) -> String {
let mut lines = Vec::new();
let is_mysql = is_mysql_like(db_type);
let cascade = if cascade_delete { " CASCADE" } else { "" };
for diff in diffs {
let table = qualified_name(&diff.name, db_type, schema);
@ -730,7 +1062,7 @@ pub fn generate_schema_sync_sql(diffs: &[TableDiff], db_type: DatabaseType, sche
if diff.diff_type == "removed" {
lines.push(format!("-- Drop {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name));
lines.push(drop_object_sql(diff, db_type, schema));
lines.push(drop_object_sql(diff, db_type, schema, cascade));
lines.push(String::new());
continue;
}
@ -853,10 +1185,8 @@ pub fn generate_schema_sync_sql(diffs: &[TableDiff], db_type: DatabaseType, sche
if let Some(foreign_keys) = &diff.foreign_keys {
for fk in foreign_keys {
if fk.diff_type == "added" || fk.diff_type == "modified" {
if let Some(source) = fk.source.as_ref() {
lines.push(add_foreign_key_sql(&diff.name, source, db_type, schema));
}
if (fk.diff_type == "added" || fk.diff_type == "modified") && fk.source.is_some() {
lines.push(add_foreign_key_sql(&diff.name, fk.source.as_ref().unwrap(), db_type, schema));
}
}
}
@ -885,6 +1215,148 @@ pub fn generate_schema_sync_sql(diffs: &[TableDiff], db_type: DatabaseType, sche
}
}
// Function diffs
if !function_diffs.is_empty() {
lines.push(String::new());
lines.push("-- Functions".to_string());
for diff in function_diffs {
match diff.diff_type.as_str() {
"added" => {
if let Some(source) = &diff.source {
lines.push(format!("-- Create function: {}", diff.name));
lines.push(format!(
"CREATE OR REPLACE FUNCTION {} {};",
qualified_name(&diff.name, db_type, schema),
source.definition
));
}
}
"removed" => {
lines.push(format!("-- Drop function: {}", diff.name));
lines.push(format!(
"DROP FUNCTION IF EXISTS {}{cascade};",
qualified_name(&diff.name, db_type, schema)
));
}
"modified" => {
if let Some(source) = &diff.source {
lines.push(format!("-- Alter function: {}", diff.name));
lines.push(format!(
"CREATE OR REPLACE FUNCTION {} {};",
qualified_name(&diff.name, db_type, schema),
source.definition
));
}
}
_ => {}
}
}
}
// Sequence diffs
if !sequence_diffs.is_empty() {
lines.push(String::new());
lines.push("-- Sequences".to_string());
for diff in sequence_diffs {
match diff.diff_type.as_str() {
"added" => {
if let Some(source) = &diff.source {
lines.push(format!("-- Create sequence: {}", diff.name));
lines.push(format!(
"CREATE SEQUENCE {} AS {} START WITH {} INCREMENT BY {} MINVALUE {} MAXVALUE {} {};",
qualified_name(&diff.name, db_type, schema),
source.data_type,
source.start_value,
source.increment,
source.min_value,
source.max_value,
if source.cycle { "CYCLE" } else { "NO CYCLE" }
));
}
}
"removed" => {
lines.push(format!("-- Drop sequence: {}", diff.name));
lines.push(format!("DROP SEQUENCE {}{cascade};", qualified_name(&diff.name, db_type, schema)));
}
"modified" => {
if let Some(source) = &diff.source {
lines.push(format!("-- Alter sequence: {}", diff.name));
lines.push(format!(
"ALTER SEQUENCE {} AS {} START WITH {} INCREMENT BY {} MINVALUE {} MAXVALUE {} {};",
qualified_name(&diff.name, db_type, schema),
source.data_type,
source.start_value,
source.increment,
source.min_value,
source.max_value,
if source.cycle { "CYCLE" } else { "NO CYCLE" }
));
}
}
_ => {}
}
}
}
// Rule diffs
if !rule_diffs.is_empty() {
lines.push(String::new());
lines.push("-- Rules".to_string());
for diff in rule_diffs {
match diff.diff_type.as_str() {
"added" => {
if let Some(source) = &diff.source {
lines.push(format!("-- Create rule: {}", diff.name));
lines.push(source.definition.clone());
}
}
"removed" => {
lines.push(format!("-- Drop rule: {}", diff.name));
if let Some(source) = &diff.source {
lines.push(format!(
"DROP RULE IF EXISTS {} ON {};",
diff.name,
qualified_name(&source.table_name, db_type, schema)
));
}
}
"modified" => {
if let Some(source) = &diff.source {
lines.push(format!("-- Alter rule: {}", diff.name));
lines.push(format!(
"DROP RULE IF EXISTS {} ON {};",
diff.name,
qualified_name(&source.table_name, db_type, schema)
));
lines.push(source.definition.clone());
}
}
_ => {}
}
}
}
// Owner diffs
if !owner_diffs.is_empty() {
lines.push(String::new());
lines.push("-- Owners".to_string());
for diff in owner_diffs {
if let (Some(source), Some(_target)) = (&diff.source, &diff.target) {
let object_type = match source.object_type.as_str() {
"TABLE" => "TABLE",
"VIEW" => "VIEW",
"SEQUENCE" => "SEQUENCE",
_ => "TABLE",
};
lines.push(format!(
"ALTER {object_type} {} OWNER TO {};",
qualified_name(&diff.object_name, db_type, schema),
source.owner
));
}
}
}
lines.join("\n").trim().to_string()
}
@ -912,8 +1384,8 @@ mod tests {
ref_schema: overrides.ref_schema,
ref_table: if overrides.ref_table.is_empty() { "users".to_string() } else { overrides.ref_table },
ref_column: if overrides.ref_column.is_empty() { "id".to_string() } else { overrides.ref_column },
on_update: None,
on_delete: None,
on_update: overrides.on_update,
on_delete: overrides.on_delete,
}
}
@ -1058,12 +1530,13 @@ mod tests {
}]),
triggers: None,
ddl: None,
target_ddl: None,
source_table_comment: None,
target_table_comment: None,
}];
assert_eq!(
generate_schema_sync_sql(&diffs, DatabaseType::Postgres, None),
generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Postgres, None, false),
[
"ALTER TABLE \"orders\" DROP CONSTRAINT \"orders_user_id_fk\";",
"DROP INDEX IF EXISTS \"idx_orders_status\";",
@ -1091,12 +1564,13 @@ mod tests {
foreign_keys: None,
triggers: None,
ddl: None,
target_ddl: None,
source_table_comment: Some(Some("用户表".to_string())),
target_table_comment: Some(Some("Users".to_string())),
}];
assert_eq!(
generate_schema_sync_sql(&diffs, DatabaseType::Mysql, None),
generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false),
[
"-- Alter table: users",
"ALTER TABLE `users`",
@ -1141,9 +1615,18 @@ mod tests {
triggers: Vec::new(),
ddl: None,
}],
source_functions: Vec::new(),
target_functions: Vec::new(),
source_sequences: Vec::new(),
target_sequences: Vec::new(),
source_rules: Vec::new(),
target_rules: Vec::new(),
source_owners: Vec::new(),
target_owners: Vec::new(),
database_type: DatabaseType::Mysql,
target_schema: None,
ignore_comments: true,
cascade_delete: false,
};
let result = prepare_schema_diff(options);
@ -1194,12 +1677,13 @@ mod tests {
foreign_keys: None,
triggers: None,
ddl: None,
target_ddl: None,
source_table_comment: None,
target_table_comment: None,
}];
assert_eq!(
generate_schema_sync_sql(&diffs, DatabaseType::Postgres, Some("sales")),
generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Postgres, Some("sales"), false),
[
"-- Alter table: orders",
"ALTER TABLE \"sales\".\"orders\" ADD COLUMN \"status\" text;",

View File

@ -116,3 +116,43 @@ pub struct TriggerInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub statement: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FunctionInfo {
pub name: String,
pub function_type: String,
pub data_type: String,
pub definition: String,
pub arguments: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SequenceInfo {
pub name: String,
pub data_type: String,
pub start_value: String,
pub min_value: String,
pub max_value: String,
pub increment: String,
pub cycle: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuleInfo {
pub name: String,
pub table_name: String,
pub definition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OwnerInfo {
pub object_name: String,
pub object_type: String,
pub owner: String,
}

View File

@ -145,6 +145,10 @@ async fn main() {
.route("/schema/indexes", get(routes::schema::list_indexes))
.route("/schema/foreign-keys", get(routes::schema::list_foreign_keys))
.route("/schema/triggers", get(routes::schema::list_triggers))
.route("/schema/functions", get(routes::schema::list_functions))
.route("/schema/sequences", get(routes::schema::list_sequences))
.route("/schema/rules", get(routes::schema::list_rules))
.route("/schema/owners", get(routes::schema::list_owners))
.route("/schema/ddl", get(routes::schema::get_ddl))
.route("/schema-diff/prepare", post(routes::schema_diff::prepare_schema_diff))
.route("/schema-diff/generate-sync-sql", post(routes::schema_diff::generate_schema_sync_sql))

View File

@ -16,7 +16,6 @@ pub struct SchemaQuery {
pub filter: Option<String>,
pub limit: Option<usize>,
pub object_type: Option<dbx_core::db::ObjectSourceKind>,
pub object_types: Option<String>,
}
pub async fn list_databases(
@ -61,17 +60,11 @@ pub async fn list_objects(
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let object_types = q.object_types.as_deref().map(parse_object_types).filter(|types| !types.is_empty());
let result = dbx_core::schema::list_objects_core(&state.app, &q.connection_id, database, schema, object_types)
.await
.map_err(AppError)?;
let result =
dbx_core::schema::list_objects_core(&state.app, &q.connection_id, database, schema).await.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
fn parse_object_types(value: &str) -> Vec<String> {
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToString::to_string).collect()
}
pub async fn list_completion_objects(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
@ -163,3 +156,63 @@ pub async fn get_ddl(
.map_err(AppError)?;
Ok(Json(result))
}
pub async fn list_functions(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let result = dbx_core::schema::list_functions_core(&state.app, &q.connection_id, database, schema)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
#[derive(Deserialize)]
pub struct SequenceQuery {
pub connection_id: String,
pub database: Option<String>,
pub schema: Option<String>,
pub with_last_values: Option<bool>,
}
pub async fn list_sequences(
State(state): State<Arc<WebState>>,
Query(q): Query<SequenceQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let result = dbx_core::schema::list_sequences_core(
&state.app,
&q.connection_id,
database,
schema,
q.with_last_values.unwrap_or(false),
)
.await
.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn list_rules(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let result =
dbx_core::schema::list_rules_core(&state.app, &q.connection_id, database, schema).await.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}
pub async fn list_owners(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let result =
dbx_core::schema::list_owners_core(&state.app, &q.connection_id, database, schema).await.map_err(AppError)?;
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
}

View File

@ -5,8 +5,13 @@ use serde::Deserialize;
#[serde(rename_all = "camelCase")]
pub struct GenerateSchemaSyncSqlRequest {
pub diffs: Vec<dbx_core::schema_diff::TableDiff>,
pub function_diffs: Option<Vec<dbx_core::schema_diff::FunctionDiff>>,
pub sequence_diffs: Option<Vec<dbx_core::schema_diff::SequenceDiff>>,
pub rule_diffs: Option<Vec<dbx_core::schema_diff::RuleDiff>>,
pub owner_diffs: Option<Vec<dbx_core::schema_diff::OwnerDiff>>,
pub database_type: dbx_core::models::connection::DatabaseType,
pub target_schema: Option<String>,
pub cascade_delete: Option<bool>,
}
pub async fn prepare_schema_diff(
@ -16,5 +21,14 @@ pub async fn prepare_schema_diff(
}
pub async fn generate_schema_sync_sql(Json(req): Json<GenerateSchemaSyncSqlRequest>) -> Json<String> {
Json(dbx_core::schema_diff::generate_schema_sync_sql(&req.diffs, req.database_type, req.target_schema.as_deref()))
Json(dbx_core::schema_diff::generate_schema_sync_sql(
&req.diffs,
req.function_diffs.as_deref().unwrap_or_default(),
req.sequence_diffs.as_deref().unwrap_or_default(),
req.rule_diffs.as_deref().unwrap_or_default(),
req.owner_diffs.as_deref().unwrap_or_default(),
req.database_type,
req.target_schema.as_deref(),
req.cascade_delete.unwrap_or(false),
))
}

View File

@ -55,6 +55,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"diff": "^9.0.0",
"dom-to-image-more": "^3.7.2",
"echarts": "^6.1.0",
"leaflet": "^1.9.4",
@ -73,6 +74,7 @@
"vue-virtual-scroller": "^3.0.4"
},
"devDependencies": {
"@lezer/highlight": "^1.2.3",
"@oxfmt/binding-darwin-arm64": "0.53.0",
"@oxlint/binding-darwin-arm64": "1.68.0",
"@tailwindcss/vite": "^4.3.0",

View File

@ -98,6 +98,9 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
diff:
specifier: ^9.0.0
version: 9.0.0
dom-to-image-more:
specifier: ^3.7.2
version: 3.7.2
@ -1013,56 +1016,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.53.0':
resolution: {integrity: sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.53.0':
resolution: {integrity: sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.53.0':
resolution: {integrity: sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.53.0':
resolution: {integrity: sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.53.0':
resolution: {integrity: sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.53.0':
resolution: {integrity: sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.53.0':
resolution: {integrity: sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.53.0':
resolution: {integrity: sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ==}
@ -1135,56 +1130,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.68.0':
resolution: {integrity: sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.68.0':
resolution: {integrity: sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.68.0':
resolution: {integrity: sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.68.0':
resolution: {integrity: sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.68.0':
resolution: {integrity: sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.68.0':
resolution: {integrity: sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.68.0':
resolution: {integrity: sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.68.0':
resolution: {integrity: sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==}
@ -1245,42 +1232,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
@ -1383,28 +1364,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@ -1473,35 +1450,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.2':
resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.2':
resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.2':
resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==}
@ -2144,6 +2116,10 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
diff@9.0.0:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
engines: {node: '>=0.3.1'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
@ -2745,28 +2721,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@ -5639,6 +5611,8 @@ snapshots:
diff@8.0.4: {}
diff@9.0.0: {}
discontinuous-range@1.0.0: {}
dom-serializer@2.0.0:

View File

@ -39,9 +39,8 @@ pub async fn list_objects(
connection_id: String,
database: String,
schema: String,
object_types: Option<Vec<String>>,
) -> Result<Vec<db::ObjectInfo>, String> {
dbx_core::schema::list_objects_core(&state, &connection_id, &database, &schema, object_types).await
dbx_core::schema::list_objects_core(&state, &connection_id, &database, &schema).await
}
#[tauri::command]
@ -120,3 +119,44 @@ pub async fn get_table_ddl(
) -> Result<String, String> {
dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table).await
}
#[tauri::command]
pub async fn list_functions(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
) -> Result<Vec<db::FunctionInfo>, String> {
dbx_core::schema::list_functions_core(&state, &connection_id, &database, &schema).await
}
#[tauri::command]
pub async fn list_sequences(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
with_last_values: bool,
) -> Result<Vec<db::SequenceInfo>, String> {
dbx_core::schema::list_sequences_core(&state, &connection_id, &database, &schema, with_last_values).await
}
#[tauri::command]
pub async fn list_rules(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
) -> Result<Vec<db::RuleInfo>, String> {
dbx_core::schema::list_rules_core(&state, &connection_id, &database, &schema).await
}
#[tauri::command]
pub async fn list_owners(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
) -> Result<Vec<db::OwnerInfo>, String> {
dbx_core::schema::list_owners_core(&state, &connection_id, &database, &schema).await
}

View File

@ -8,8 +8,22 @@ pub fn prepare_schema_diff(
#[tauri::command]
pub fn generate_schema_sync_sql(
diffs: Vec<dbx_core::schema_diff::TableDiff>,
function_diffs: Option<Vec<dbx_core::schema_diff::FunctionDiff>>,
sequence_diffs: Option<Vec<dbx_core::schema_diff::SequenceDiff>>,
rule_diffs: Option<Vec<dbx_core::schema_diff::RuleDiff>>,
owner_diffs: Option<Vec<dbx_core::schema_diff::OwnerDiff>>,
database_type: dbx_core::models::connection::DatabaseType,
target_schema: Option<String>,
cascade_delete: Option<bool>,
) -> Result<String, String> {
Ok(dbx_core::schema_diff::generate_schema_sync_sql(&diffs, database_type, target_schema.as_deref()))
Ok(dbx_core::schema_diff::generate_schema_sync_sql(
&diffs,
function_diffs.as_deref().unwrap_or_default(),
sequence_diffs.as_deref().unwrap_or_default(),
rule_diffs.as_deref().unwrap_or_default(),
owner_diffs.as_deref().unwrap_or_default(),
database_type,
target_schema.as_deref(),
cascade_delete.unwrap_or(false),
))
}

View File

@ -369,6 +369,10 @@ pub fn run() {
commands::schema::list_foreign_keys,
commands::schema::list_triggers,
commands::schema::get_table_ddl,
commands::schema::list_functions,
commands::schema::list_sequences,
commands::schema::list_rules,
commands::schema::list_owners,
commands::schema_diff::prepare_schema_diff,
commands::schema_diff::generate_schema_sync_sql,
commands::schema_cache::save_schema_cache,