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:
parent
2554ed95be
commit
ef5ed05ae7
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1555,7 +1555,7 @@
|
|||
},
|
||||
},
|
||||
diff: {
|
||||
title: "Confronta Database",
|
||||
title: "Confronta Schemi",
|
||||
source: "Sorgente",
|
||||
target: "Destinazione",
|
||||
selectConnection: "Seleziona connessione",
|
||||
|
|
|
|||
|
|
@ -1566,7 +1566,7 @@
|
|||
},
|
||||
},
|
||||
diff: {
|
||||
title: "Comparar Bancos de Dados",
|
||||
title: "Comparar Esquemas",
|
||||
source: "Origem",
|
||||
target: "Destino",
|
||||
selectConnection: "Selecionar conexão",
|
||||
|
|
|
|||
|
|
@ -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: "比较数据",
|
||||
|
|
|
|||
|
|
@ -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: "比較資料",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()))?))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue