feat(schema-diff): select fields and indexes for synchronization
This commit is contained in:
1 parent
d8bc5153d2
commit
726f86e24c
32 files changed
+1185
-413
No files matched your search
@@ -105,6 +105,15 @@ function handleUpdateSelectedTables(value: string[]) {
|
||||
if (restrictTables.value) emit("update:selectedTables", [...value]);
|
||||
}
|
||||
|
||||
function clearUnavailableTableSelection() {
|
||||
sourceTableList.value = [];
|
||||
targetTableList.value = [];
|
||||
if (props.selectedTables === undefined && !restrictTables.value && localSelectedTables.value.length === 0) return;
|
||||
restrictTables.value = false;
|
||||
localSelectedTables.value = [];
|
||||
emit("update:selectedTables", undefined);
|
||||
}
|
||||
|
||||
function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
|
||||
return side === "source" ? { connectionId: props.sourceConnectionId, database: props.sourceDatabase, schema: props.sourceSchema } : { connectionId: props.targetConnectionId, database: props.targetDatabase, schema: props.targetSchema };
|
||||
}
|
||||
@@ -277,13 +286,17 @@ watch(
|
||||
selectedTables: props.selectedTables,
|
||||
}),
|
||||
(current, previous) => {
|
||||
if (!isTableIdentityReady("source")) {
|
||||
clearUnavailableTableSelection();
|
||||
return;
|
||||
}
|
||||
if (!previous) return;
|
||||
if (current.source.every((value, index) => value === previous.source[index])) return;
|
||||
if (current.selectedTables !== previous.selectedTables) return;
|
||||
if (current.selectedTables === undefined && !restrictTables.value) return;
|
||||
restrictTables.value = false;
|
||||
localSelectedTables.value = [];
|
||||
emit("update:selectedTables", undefined);
|
||||
clearUnavailableTableSelection();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -446,7 +459,15 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.tableSelectionUnrestricted") }}
|
||||
</div>
|
||||
<TableMultiSelect v-if="restrictTables" :key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`" :model-value="localSelectedTables" @update:model-value="handleUpdateSelectedTables" :tables="sourceTableList" :title="t('diff.sourceTables')" :empty-text="t('dataCompare.noTables')" />
|
||||
<TableMultiSelect
|
||||
v-if="restrictTables && canConfigureTableSelection"
|
||||
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
|
||||
:model-value="localSelectedTables"
|
||||
@update:model-value="handleUpdateSelectedTables"
|
||||
:tables="sourceTableList"
|
||||
:title="t('diff.sourceTables')"
|
||||
:empty-text="t('dataCompare.noTables')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Source Info -->
|
||||
@@ -535,7 +556,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
</div>
|
||||
|
||||
<!-- Target Same-Name Match -->
|
||||
<div v-if="restrictTables && localSelectedTables.length" class="mt-3 space-y-1.5 rounded-lg border p-3 text-xs">
|
||||
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="mt-3 space-y-1.5 rounded-lg border p-3 text-xs">
|
||||
<div class="font-medium">{{ t("diff.autoMatchHint") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 { buildSchemaDiffHighlightSegments, type SchemaDiffHighlightSegment } from "@/lib/schema/schemaDiffHighlight";
|
||||
import { findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
|
||||
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
|
||||
import { FileCode, ScrollText, Copy, Play, FileDiff } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
@@ -34,6 +36,7 @@ function toRgba(hex: string, alpha: number): string {
|
||||
|
||||
const props = defineProps<{
|
||||
selectedObject: SchemaDiffObject | null;
|
||||
focusedObject?: SchemaDiffObject | null;
|
||||
deploySql: string;
|
||||
deploySqlAll: string;
|
||||
compatibilityWarnings?: CompatibilityWarning[];
|
||||
@@ -57,6 +60,8 @@ const leftPaneRef = ref<HTMLDivElement>();
|
||||
const rightPaneRef = ref<HTMLDivElement>();
|
||||
const containerSize = ref({ width: 0, height: 0 });
|
||||
const connectorKey = ref(0);
|
||||
const focusedSourceLineNumber = ref<number | null>(null);
|
||||
const focusedTargetLineNumber = ref<number | null>(null);
|
||||
|
||||
const rollbackDiffContainerRef = ref<HTMLDivElement>();
|
||||
const rollbackLeftPaneRef = ref<HTMLDivElement>();
|
||||
@@ -89,7 +94,7 @@ const { syncScroll: rollbackSyncScroll, measureHunks: rollbackMeasureHunks } = u
|
||||
});
|
||||
|
||||
function collectModifySegments(diffHunks: ReturnType<typeof buildHunks>) {
|
||||
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
|
||||
const map = new Map<string, { leftSegments: SchemaDiffHighlightSegment[]; rightSegments: SchemaDiffHighlightSegment[] }>();
|
||||
for (const hunk of diffHunks) {
|
||||
for (let i = 0; i < hunk.leftLines.length; i++) {
|
||||
const left = hunk.leftLines[i];
|
||||
@@ -99,13 +104,13 @@ function collectModifySegments(diffHunks: ReturnType<typeof buildHunks>) {
|
||||
map.set(key, renderModifyLine(left, right));
|
||||
} else if (left.type === "modify" && !left.isPadding && left.comparisonContent !== undefined) {
|
||||
map.set(key, {
|
||||
leftSegments: renderModifyContent(left.content, left.comparisonContent).sourceSegments,
|
||||
leftSegments: buildSchemaDiffHighlightSegments(left.content, left.comparisonContent).sourceSegments,
|
||||
rightSegments: [],
|
||||
});
|
||||
} else if (right.type === "modify" && !right.isPadding && right.comparisonContent !== undefined) {
|
||||
map.set(key, {
|
||||
leftSegments: [],
|
||||
rightSegments: renderModifyContent(right.comparisonContent, right.content).targetSegments,
|
||||
rightSegments: buildSchemaDiffHighlightSegments(right.comparisonContent, right.content).targetSegments,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -177,14 +182,46 @@ function rollbackUpdateContainerSize() {
|
||||
rollbackContainerSize.value = { width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedObject?.id,
|
||||
async () => {
|
||||
await nextTick();
|
||||
updateContainerSize();
|
||||
requestMeasure();
|
||||
},
|
||||
);
|
||||
watch([() => props.selectedObject?.id, () => props.focusedObject?.id, hunks], async () => {
|
||||
if (props.focusedObject?.parentId) activeTab.value = "ddl";
|
||||
await nextTick();
|
||||
updateContainerSize();
|
||||
requestMeasure();
|
||||
locateFocusedObject();
|
||||
});
|
||||
|
||||
function scrollPaneToLine(pane: HTMLDivElement | undefined, lineNumber: number | null) {
|
||||
if (!pane || lineNumber === null) return;
|
||||
const row = pane.querySelector<HTMLElement>(`[data-ddl-line-number="${lineNumber}"]`);
|
||||
if (!row) return;
|
||||
const paneRect = pane.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
pane.scrollTo({
|
||||
top: Math.max(0, pane.scrollTop + rowRect.top - paneRect.top - pane.clientHeight / 2 + rowRect.height / 2),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function locateFocusedObject() {
|
||||
const selectedObject = props.selectedObject;
|
||||
const focusedObject = props.focusedObject;
|
||||
if (!selectedObject || !focusedObject || focusedObject.id === selectedObject.id) {
|
||||
focusedSourceLineNumber.value = null;
|
||||
focusedTargetLineNumber.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
focusedSourceLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.sourceDdl ?? "", focusedObject, "source");
|
||||
focusedTargetLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.targetDdl ?? "", focusedObject, "target");
|
||||
scrollPaneToLine(leftPaneRef.value, focusedSourceLineNumber.value);
|
||||
scrollPaneToLine(rightPaneRef.value, focusedTargetLineNumber.value);
|
||||
}
|
||||
|
||||
function focusedLineClass(line: DiffLine, side: "source" | "target"): string {
|
||||
const focusedLineNumber = side === "source" ? focusedSourceLineNumber.value : focusedTargetLineNumber.value;
|
||||
if (line.isPadding || focusedLineNumber === null) return "";
|
||||
return line.lineNumber === focusedLineNumber ? "outline outline-1 -outline-offset-1 outline-primary/70" : "";
|
||||
}
|
||||
|
||||
function updateContainerSize() {
|
||||
const el = diffContainerRef.value;
|
||||
@@ -225,86 +262,11 @@ function lineTextClass(line: DiffLine): string {
|
||||
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 { sourceSegments, targetSegments } = renderModifyContent(leftLine.content, rightLine.content);
|
||||
function renderModifyLine(leftLine: DiffLine, rightLine: DiffLine): { leftSegments: SchemaDiffHighlightSegment[]; rightSegments: SchemaDiffHighlightSegment[] } {
|
||||
const { sourceSegments, targetSegments } = buildSchemaDiffHighlightSegments(leftLine.content, rightLine.content);
|
||||
return { leftSegments: sourceSegments, rightSegments: targetSegments };
|
||||
}
|
||||
|
||||
function renderModifyContent(source: string, target: string): { sourceSegments: Segment[]; targetSegments: Segment[] } {
|
||||
const charDiffs = computeCharDiffs(source, target);
|
||||
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 { sourceSegments: leftSegments, targetSegments: rightSegments };
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
function copyDeploySql() {
|
||||
copyToClipboard(props.deploySql);
|
||||
toast(t("diff.copied"), 2000);
|
||||
@@ -386,19 +348,11 @@ function copyDeploySqlAll() {
|
||||
<!-- 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">
|
||||
<div class="sticky top-0 bg-background 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 rounded-sm border-yellow-500/40': line.type === 'modify',
|
||||
}"
|
||||
:style="{ backgroundColor: lineBackground(line) }"
|
||||
>
|
||||
<div v-for="(line, idx) in hunk.leftLines" :key="`l-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :class="focusedLineClass(line, 'source')" :data-ddl-line-number="line.lineNumber ?? undefined" :style="{ backgroundColor: lineBackground(line) }">
|
||||
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
|
||||
{{ line.lineNumber ?? "" }}
|
||||
</span>
|
||||
@@ -418,19 +372,11 @@ function copyDeploySqlAll() {
|
||||
<!-- 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">
|
||||
<div class="sticky top-0 bg-background 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 rounded-sm border-yellow-500/40': line.type === 'modify',
|
||||
}"
|
||||
:style="{ backgroundColor: lineBackground(line) }"
|
||||
>
|
||||
<div v-for="(line, idx) in hunk.rightLines" :key="`r-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :class="focusedLineClass(line, 'target')" :data-ddl-line-number="line.lineNumber ?? undefined" :style="{ backgroundColor: lineBackground(line) }">
|
||||
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
|
||||
{{ line.lineNumber ?? "" }}
|
||||
</span>
|
||||
@@ -462,19 +408,11 @@ function copyDeploySqlAll() {
|
||||
<Splitpanes class="h-full" @resized="rollbackOnSplitpanesResized">
|
||||
<Pane min-size="20">
|
||||
<div ref="rollbackLeftPaneRef" class="h-full overflow-y-auto border-r" @scroll="rollbackHandleScroll('left')">
|
||||
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
|
||||
<div class="sticky top-0 bg-background px-3 py-1.5 text-xs font-medium border-b z-10">
|
||||
{{ t("rollbackComparison.forwardSql") }}
|
||||
</div>
|
||||
<div v-for="hunk in rollbackHunks" :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 rounded-sm border-yellow-500/40': line.type === 'modify',
|
||||
}"
|
||||
:style="{ backgroundColor: lineBackground(line) }"
|
||||
>
|
||||
<div v-for="(line, idx) in hunk.leftLines" :key="`l-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :style="{ backgroundColor: lineBackground(line) }">
|
||||
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
|
||||
{{ line.lineNumber ?? "" }}
|
||||
</span>
|
||||
@@ -492,19 +430,11 @@ function copyDeploySqlAll() {
|
||||
</Pane>
|
||||
<Pane min-size="20">
|
||||
<div ref="rollbackRightPaneRef" class="h-full overflow-y-auto" @scroll="rollbackHandleScroll('right')">
|
||||
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
|
||||
<div class="sticky top-0 bg-background px-3 py-1.5 text-xs font-medium border-b z-10">
|
||||
{{ t("rollbackComparison.rollbackSql") }}
|
||||
</div>
|
||||
<div v-for="hunk in rollbackHunks" :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 rounded-sm border-yellow-500/40': line.type === 'modify',
|
||||
}"
|
||||
:style="{ backgroundColor: lineBackground(line) }"
|
||||
>
|
||||
<div v-for="(line, idx) in hunk.rightLines" :key="`r-${hunk.id}-${idx}`" class="flex min-h-[1.5em]" :style="{ backgroundColor: lineBackground(line) }">
|
||||
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
|
||||
{{ line.lineNumber ?? "" }}
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,20 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { schemaDiffReviewAlert, selectedSchemaDiffObjects, summarizeSchemaDiffOperations, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type CompatibilityWarning, type RenameCandidate, type MissingRollbackObject, type RollbackCompleteness } from "@/lib/schema/schemaDiff";
|
||||
import {
|
||||
schemaDiffObjectSelectionState,
|
||||
schemaDiffReviewAlert,
|
||||
schemaDiffSelectionTargets,
|
||||
selectedSchemaDiffObjects,
|
||||
summarizeSchemaDiffOperations,
|
||||
type SchemaDiffObject,
|
||||
type DiffOperationType,
|
||||
type DiffObjectKind,
|
||||
type CompatibilityWarning,
|
||||
type RenameCandidate,
|
||||
type MissingRollbackObject,
|
||||
type RollbackCompleteness,
|
||||
} from "@/lib/schema/schemaDiff";
|
||||
import ImpactReportPanel from "@/components/diff/ImpactReportPanel.vue";
|
||||
import type { ImpactReport } from "@/types/governance";
|
||||
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, AlertTriangle, ShieldCheck } from "@lucide/vue";
|
||||
@@ -54,13 +67,13 @@ 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}`];
|
||||
const patterns = [`-- Create ${obj.objectKind}: ${obj.name}`, `-- Alter ${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+:/);
|
||||
const nextMatch = remaining.match(/--\s*(Create|Alter|Modify|Drop)\s+\w+:/);
|
||||
if (nextMatch && nextMatch.index !== undefined) {
|
||||
endPos = index + pattern.length + nextMatch.index;
|
||||
}
|
||||
@@ -90,8 +103,8 @@ 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;
|
||||
const state = schemaDiffObjectSelectionState(o);
|
||||
return o.operationType !== "none" && (state.checked || state.indeterminate);
|
||||
})
|
||||
.sort((a, b) => operationOrder[a.operationType] - operationOrder[b.operationType]);
|
||||
});
|
||||
@@ -338,6 +351,7 @@ function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
<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 v-if="obj.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(obj).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(obj).length }}</span>
|
||||
<span class="text-[10px] text-muted-foreground shrink-0 ml-auto">
|
||||
{{ getOperationLabel(obj.operationType) }}
|
||||
</span>
|
||||
|
||||
@@ -22,24 +22,26 @@ import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurr
|
||||
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
|
||||
import type { ObjectSourceKind, TableInfo } from "@/types/database";
|
||||
import type { DatabaseType, ObjectSourceKind, TableInfo } from "@/types/database";
|
||||
import {
|
||||
buildDeploySqlForObjects,
|
||||
convertToSchemaDiffObjects,
|
||||
detectDestructiveSchemaDiffStatements,
|
||||
groupDiffObjects,
|
||||
injectColumnRenameSql,
|
||||
schemaDiffDeployTargetSchema,
|
||||
schemaDiffSelectionOwnerId,
|
||||
findSchemaDiffObject,
|
||||
flattenSchemaDiffObjects,
|
||||
schemaDiffSelectionTargets,
|
||||
selectSchemaDiffInput,
|
||||
selectedSchemaDiffObjects,
|
||||
setSchemaDiffObjectSelected,
|
||||
setSchemaDiffObjectSelectedWithDependencies,
|
||||
summarizeSchemaDiffOperations,
|
||||
databaseTypeToDialectKind,
|
||||
normalizeDialectKind,
|
||||
type OperationGroup,
|
||||
type SchemaDiffObject,
|
||||
type DiffOperationType,
|
||||
type DiffObjectKind,
|
||||
type SchemaDiffPreparation,
|
||||
type MissingRollbackObject,
|
||||
type RollbackCompleteness,
|
||||
@@ -122,6 +124,7 @@ const renameCandidates = ref<RenameCandidate[]>([]);
|
||||
const compatibilityWarnings = ref<CompatibilityWarning[]>([]);
|
||||
const permissionDiffs = ref<PermissionDiff[]>([]);
|
||||
const dependencyGraph = ref<DependencyGraph | null>(null);
|
||||
let deploySqlGeneration = 0;
|
||||
|
||||
// Rename candidates panel
|
||||
const showRenamePanel = ref(true);
|
||||
@@ -250,25 +253,54 @@ const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfi
|
||||
const schemaDiffPanelOptions = computed(() => normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()));
|
||||
|
||||
const selectedObject = computed(() => {
|
||||
const object = selectedTreeObject.value;
|
||||
if (!object) return null;
|
||||
return object.parentId ? (findSchemaDiffObject(diffObjects.value, object.parentId) ?? object) : object;
|
||||
});
|
||||
|
||||
const selectedTreeObject = computed(() => {
|
||||
if (!selectedObjectId.value) return null;
|
||||
for (const group of diffGroups.value) {
|
||||
for (const typeGroup of group.typeGroups) {
|
||||
const obj = typeGroup.objects.find((o) => o.id === selectedObjectId.value);
|
||||
if (obj) return obj;
|
||||
const object = flattenSchemaDiffObjects(typeGroup.objects).find((candidate) => candidate.id === selectedObjectId.value);
|
||||
if (object) return object;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const canDeploy = computed(() => {
|
||||
return diffObjects.value.some((o) => o.selected && o.operationType !== "none");
|
||||
return selectedSchemaDiffObjects(diffObjects.value).length > 0;
|
||||
});
|
||||
|
||||
function resetComparisonResultState() {
|
||||
deploySqlGeneration++;
|
||||
step.value = "config";
|
||||
diffObjects.value = [];
|
||||
diffGroups.value = [];
|
||||
selectedObjectId.value = null;
|
||||
deploySql.value = "";
|
||||
deploySqlAll.value = "";
|
||||
lastDiffResult.value = null;
|
||||
rollbackSql.value = "";
|
||||
rollbackCompleteness.value = "complete";
|
||||
missingRollbackObjects.value = [];
|
||||
renameCandidates.value = [];
|
||||
compatibilityWarnings.value = [];
|
||||
permissionDiffs.value = [];
|
||||
dependencyGraph.value = null;
|
||||
deploySqlMode.value = "forward";
|
||||
showConfirmDialog.value = false;
|
||||
showResultDialog.value = false;
|
||||
deployResult.value = null;
|
||||
}
|
||||
|
||||
// Watch for prefilled values
|
||||
watch(
|
||||
() => open.value,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetComparisonResultState();
|
||||
ensureDefaultConfig();
|
||||
if (props.prefillConnectionId) {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
@@ -308,7 +340,7 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
function getDbType(): string {
|
||||
function getDbType(): DatabaseType {
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
return targetConfig?.db_type || "postgres";
|
||||
}
|
||||
@@ -528,7 +560,7 @@ async function handleCompare() {
|
||||
}
|
||||
}
|
||||
deploySqlMode.value = "forward";
|
||||
regenerateDeploySql();
|
||||
await regenerateDeploySql();
|
||||
|
||||
step.value = "result";
|
||||
} catch (e: any) {
|
||||
@@ -543,39 +575,29 @@ function handleToggleGroup(operationType: DiffOperationType) {
|
||||
diffGroups.value = diffGroups.value.map((g) => (g.operationType === operationType ? { ...g, expanded: !g.expanded } : g));
|
||||
}
|
||||
|
||||
function handleToggleTypeGroup(operationType: DiffOperationType, kind: DiffObjectKind) {
|
||||
diffGroups.value = diffGroups.value.map((g) => {
|
||||
if (g.operationType !== operationType) return g;
|
||||
return {
|
||||
...g,
|
||||
typeGroups: g.typeGroups.map((tg) => (tg.kind === kind ? { ...tg, expanded: !tg.expanded } : tg)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleGroupSelection(operationType: DiffOperationType, selected: boolean) {
|
||||
const group = diffGroups.value.find((candidate) => candidate.operationType === operationType);
|
||||
for (const object of group?.typeGroups.flatMap((typeGroup) => typeGroup.objects) ?? []) {
|
||||
setSchemaDiffObjectSelected(diffObjects.value, schemaDiffSelectionOwnerId(object), selected);
|
||||
for (const target of schemaDiffSelectionTargets(object)) {
|
||||
updateObjectSelection(target.id, selected);
|
||||
}
|
||||
}
|
||||
rebuildDiffGroups();
|
||||
regenerateDeploySql();
|
||||
void regenerateDeploySql();
|
||||
}
|
||||
|
||||
function handleToggleTypeSelection(operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean) {
|
||||
const typeGroup = diffGroups.value.find((group) => group.operationType === operationType)?.typeGroups.find((candidate) => candidate.kind === kind);
|
||||
for (const object of typeGroup?.objects ?? []) {
|
||||
setSchemaDiffObjectSelected(diffObjects.value, schemaDiffSelectionOwnerId(object), selected);
|
||||
function handleToggleObjectSelection(object: SchemaDiffObject, selected: boolean) {
|
||||
let changed = false;
|
||||
for (const target of schemaDiffSelectionTargets(object)) {
|
||||
changed = updateObjectSelection(target.id, selected) || changed;
|
||||
}
|
||||
if (!changed) return;
|
||||
rebuildDiffGroups();
|
||||
regenerateDeploySql();
|
||||
void regenerateDeploySql();
|
||||
}
|
||||
|
||||
function handleToggleObjectSelection(objectId: string, selected: boolean) {
|
||||
const reviewObject = diffGroups.value.flatMap((group) => group.typeGroups.flatMap((typeGroup) => typeGroup.objects)).find((object) => object.id === objectId);
|
||||
if (!setSchemaDiffObjectSelected(diffObjects.value, reviewObject ? schemaDiffSelectionOwnerId(reviewObject) : objectId, selected)) return;
|
||||
rebuildDiffGroups();
|
||||
regenerateDeploySql();
|
||||
function updateObjectSelection(objectId: string, selected: boolean): boolean {
|
||||
return lastDiffResult.value ? setSchemaDiffObjectSelectedWithDependencies(diffObjects.value, lastDiffResult.value, objectId, selected) : setSchemaDiffObjectSelected(diffObjects.value, objectId, selected);
|
||||
}
|
||||
|
||||
function rebuildDiffGroups() {
|
||||
@@ -591,8 +613,43 @@ function rebuildDiffGroups() {
|
||||
}));
|
||||
}
|
||||
|
||||
function regenerateDeploySql() {
|
||||
deploySql.value = buildDeploySqlForObjects(diffObjects.value);
|
||||
async function regenerateDeploySql() {
|
||||
const result = lastDiffResult.value;
|
||||
if (!result) {
|
||||
deploySql.value = "-- No objects selected";
|
||||
rollbackSql.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = ++deploySqlGeneration;
|
||||
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType());
|
||||
const input = selectSchemaDiffInput(result, diffObjects.value);
|
||||
let plan;
|
||||
try {
|
||||
plan = await api.generateSchemaSyncPlan(input, {
|
||||
databaseType: getDbType(),
|
||||
targetSchema: schemaDiffDeployTargetSchema(getDbType(), targetDatabase.value, targetSchema.value),
|
||||
cascadeDelete: options.cascadeDelete,
|
||||
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceDbType.value ? databaseTypeToDialectKind(sourceDbType.value) : undefined,
|
||||
fieldMappings: options.fieldMappings,
|
||||
enableRollback: options.enableRollback,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (generation === deploySqlGeneration) toast(error?.message || String(error), 5000);
|
||||
return;
|
||||
}
|
||||
if (generation !== deploySqlGeneration) return;
|
||||
|
||||
let forwardSql = plan.syncSql || "-- No objects selected";
|
||||
let nextRollbackSql = plan.rollbackSyncSql ?? "";
|
||||
rollbackCompleteness.value = plan.rollbackCompleteness ?? "complete";
|
||||
missingRollbackObjects.value = plan.missingRollbackObjects ?? [];
|
||||
if (options.detectRenames && options.renameThreshold) {
|
||||
forwardSql = injectColumnRenameSql(forwardSql, input.diffs, options.renameThreshold);
|
||||
if (nextRollbackSql) nextRollbackSql = injectColumnRenameSql(nextRollbackSql, input.diffs, options.renameThreshold, true);
|
||||
}
|
||||
rollbackSql.value = nextRollbackSql;
|
||||
deploySql.value = deploySqlMode.value === "rollback" && nextRollbackSql ? nextRollbackSql : forwardSql;
|
||||
}
|
||||
|
||||
function switchDeploySqlMode(mode: "forward" | "rollback") {
|
||||
@@ -604,7 +661,7 @@ function switchDeploySqlMode(mode: "forward" | "rollback") {
|
||||
if (mode === "rollback" && rollbackSql.value) {
|
||||
deploySql.value = rollbackSql.value;
|
||||
} else {
|
||||
regenerateDeploySql();
|
||||
void regenerateDeploySql();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +700,8 @@ function applyRename(rc: RenameCandidate) {
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
regenerateDeploySql();
|
||||
rebuildDiffGroups();
|
||||
void regenerateDeploySql();
|
||||
toast(t("diff.renameApplied"), 2000);
|
||||
}
|
||||
}
|
||||
@@ -659,11 +717,12 @@ function ignoreRename(index: number) {
|
||||
}
|
||||
}
|
||||
renameCandidates.value.splice(index, 1);
|
||||
regenerateDeploySql();
|
||||
rebuildDiffGroups();
|
||||
void regenerateDeploySql();
|
||||
}
|
||||
|
||||
async function handleExecuteScript() {
|
||||
if (!deploySql.value || deploySql.value.startsWith("-- ")) {
|
||||
if (!deploySql.value.trim() || deploySql.value.trim() === "-- No objects selected") {
|
||||
toast(t("diff.noObjectsSelected"), 3000);
|
||||
return;
|
||||
}
|
||||
@@ -706,8 +765,9 @@ function showDeployTxResult(txLog: any) {
|
||||
deployResult.value = buildDeployTxResult(txLog, t);
|
||||
showResultDialog.value = true;
|
||||
}
|
||||
async function handleSelectObject(obj: SchemaDiffObject) {
|
||||
selectedObjectId.value = obj.id;
|
||||
async function handleSelectObject(reviewObject: SchemaDiffObject) {
|
||||
selectedObjectId.value = reviewObject.id;
|
||||
const obj = reviewObject.parentId ? (findSchemaDiffObject(diffObjects.value, reviewObject.parentId) ?? reviewObject) : reviewObject;
|
||||
|
||||
// Dynamically fetch DDL for objects that don't have pre-generated DDL
|
||||
// (views need runtime retrieval; functions should already have definition)
|
||||
@@ -806,7 +866,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
}
|
||||
|
||||
function handleDeployReview() {
|
||||
const selectedObjects = diffObjects.value.filter((o) => o.selected && o.operationType !== "none");
|
||||
const selectedObjects = selectedSchemaDiffObjects(diffObjects.value);
|
||||
if (selectedObjects.length === 0) {
|
||||
toast(t("diff.noObjectsSelected"), 3000);
|
||||
return;
|
||||
@@ -843,6 +903,13 @@ const deployStats = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const selectedCompatibilityWarnings = computed(() => {
|
||||
if (!lastDiffResult.value) return [];
|
||||
const input = selectSchemaDiffInput(lastDiffResult.value, diffObjects.value);
|
||||
const selectedColumns = new Set(input.diffs.flatMap((diff) => (diff.columns ?? []).map((column) => `${diff.name}\u0000${column.name}`)));
|
||||
return compatibilityWarnings.value.filter((warning) => selectedColumns.has(`${warning.table}\u0000${warning.column}`));
|
||||
});
|
||||
|
||||
const targetConnectionInfo = computed(() => {
|
||||
const config = store.getConfig(targetConnectionId.value);
|
||||
if (!config) return null;
|
||||
@@ -959,24 +1026,16 @@ const targetConnectionInfo = computed(() => {
|
||||
<Splitpanes horizontal class="flex-1 min-h-0" @resized="handleSplitpanesResized">
|
||||
<Pane :size="splitpanesSize" min-size="20">
|
||||
<div class="h-full overflow-auto">
|
||||
<SchemaDiffObjectTree
|
||||
:groups="diffGroups"
|
||||
:selected-object-id="selectedObject?.id ?? null"
|
||||
@toggle-group="handleToggleGroup"
|
||||
@toggle-type-group="handleToggleTypeGroup"
|
||||
@toggle-group-selection="handleToggleGroupSelection"
|
||||
@toggle-type-selection="handleToggleTypeSelection"
|
||||
@toggle-object-selection="handleToggleObjectSelection"
|
||||
@select-object="handleSelectObject"
|
||||
/>
|
||||
<SchemaDiffObjectTree :groups="diffGroups" :selected-object-id="selectedObjectId" @toggle-group="handleToggleGroup" @toggle-group-selection="handleToggleGroupSelection" @toggle-object-selection="handleToggleObjectSelection" @select-object="handleSelectObject" />
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="100 - splitpanesSize" min-size="20">
|
||||
<SchemaDiffDdlPanel
|
||||
:selected-object="selectedObject"
|
||||
:focused-object="selectedTreeObject"
|
||||
:deploy-sql="deploySql"
|
||||
:deploy-sql-all="deploySqlAll"
|
||||
:compatibility-warnings="compatibilityWarnings"
|
||||
:compatibility-warnings="selectedCompatibilityWarnings"
|
||||
:rollback-sql="rollbackSql"
|
||||
:deploy-sql-mode="deploySqlMode"
|
||||
:dependency-graph="dependencyGraph"
|
||||
@@ -1002,7 +1061,7 @@ const targetConnectionInfo = computed(() => {
|
||||
:executing="executing"
|
||||
:rollback-sql="rollbackSql"
|
||||
:deploy-sql-mode="deploySqlMode"
|
||||
:compatibility-warnings="compatibilityWarnings"
|
||||
:compatibility-warnings="selectedCompatibilityWarnings"
|
||||
:rename-candidates="renameCandidates"
|
||||
:rollback-completeness="rollbackCompleteness"
|
||||
:missing-rollback-objects="missingRollbackObjects"
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schema/schemaDiff";
|
||||
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
|
||||
import { schemaDiffObjectSelectionState, schemaDiffSelectionTargets, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type OperationGroup } from "@/lib/schema/schemaDiff";
|
||||
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, SlidersHorizontal, 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 expandedObjectIds = ref(new Set<string>());
|
||||
|
||||
const props = defineProps<{
|
||||
groups: OperationGroup[];
|
||||
@@ -28,10 +14,8 @@ const props = defineProps<{
|
||||
|
||||
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: "toggleObjectSelection", object: SchemaDiffObject, selected: boolean): void;
|
||||
(e: "selectObject", object: SchemaDiffObject): void;
|
||||
}>();
|
||||
|
||||
@@ -78,6 +62,8 @@ function getObjectIcon(kind: DiffObjectKind) {
|
||||
return Link2;
|
||||
case "trigger":
|
||||
return Zap;
|
||||
case "tableOption":
|
||||
return SlidersHorizontal;
|
||||
default:
|
||||
return Table;
|
||||
}
|
||||
@@ -105,144 +91,137 @@ function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
return "text-lime-500";
|
||||
case "trigger":
|
||||
return "text-rose-500";
|
||||
case "tableOption":
|
||||
return "text-muted-foreground";
|
||||
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 "column":
|
||||
return "diff.objectKindLabel.column";
|
||||
case "index":
|
||||
return "diff.objectKindLabel.index";
|
||||
case "foreignKey":
|
||||
return "diff.objectKindLabel.foreignKey";
|
||||
case "trigger":
|
||||
return "diff.objectKindLabel.trigger";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
function groupObjects(group: OperationGroup): SchemaDiffObject[] {
|
||||
return group.typeGroups.flatMap((typeGroup) => typeGroup.objects);
|
||||
}
|
||||
|
||||
function isGroupFullySelected(group: OperationGroup): boolean {
|
||||
return group.count > 0 && group.selectedCount === group.count;
|
||||
function groupSelectionState(group: OperationGroup): { checked: boolean; indeterminate: boolean } {
|
||||
const targets = groupObjects(group).flatMap(schemaDiffSelectionTargets);
|
||||
const selectedCount = targets.filter((target) => target.selected).length;
|
||||
return {
|
||||
checked: targets.length > 0 && selectedCount === targets.length,
|
||||
indeterminate: selectedCount > 0 && selectedCount < targets.length,
|
||||
};
|
||||
}
|
||||
|
||||
function isGroupPartiallySelected(group: OperationGroup): boolean {
|
||||
return group.selectedCount > 0 && group.selectedCount < group.count;
|
||||
function toggleObjectExpanded(objectId: string) {
|
||||
const next = new Set(expandedObjectIds.value);
|
||||
if (next.has(objectId)) next.delete(objectId);
|
||||
else next.add(objectId);
|
||||
expandedObjectIds.value = next;
|
||||
}
|
||||
|
||||
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 isObjectExpanded(objectId: string): boolean {
|
||||
return expandedObjectIds.value.has(objectId);
|
||||
}
|
||||
|
||||
function onGroupCheckboxChange(group: OperationGroup, event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
emit("toggleGroupSelection", group.operationType, checked);
|
||||
emit("toggleGroupSelection", group.operationType, (event.target as HTMLInputElement).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(object: SchemaDiffObject, event: Event) {
|
||||
emit("toggleObjectSelection", object, (event.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
function onObjectCheckboxChange(obj: SchemaDiffObject, event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
emit("toggleObjectSelection", obj.id, checked);
|
||||
function displayObjectName(object: SchemaDiffObject): string {
|
||||
if (object.objectKind === "tableOption") return t("diff.objectKindLabel.tableOption");
|
||||
if (object.objectKind === "function" && object.arguments) return `${object.name}(${object.arguments})`;
|
||||
return object.name;
|
||||
}
|
||||
|
||||
function formatObjectName(obj: SchemaDiffObject): string {
|
||||
const name = obj.parentName ? `${obj.parentName}.${obj.name}` : obj.name;
|
||||
if (obj.objectKind === "function" && obj.arguments) {
|
||||
return `${name}(${obj.arguments})`;
|
||||
}
|
||||
return name;
|
||||
function sourceObjectName(object: SchemaDiffObject): string {
|
||||
return object.sourceName ?? displayObjectName(object);
|
||||
}
|
||||
|
||||
function targetObjectName(object: SchemaDiffObject): string {
|
||||
return object.targetName ?? displayObjectName(object);
|
||||
}
|
||||
</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 -->
|
||||
<div v-for="group in props.groups" :key="group.operationType" class="border rounded-md overflow-hidden">
|
||||
<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)" />
|
||||
|
||||
<input type="checkbox" class="accent-primary shrink-0" :checked="groupSelectionState(group).checked" :indeterminate="groupSelectionState(group).indeterminate" :disabled="group.count === 0" @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>
|
||||
<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" />
|
||||
<div v-for="object in groupObjects(group)" :key="object.id">
|
||||
<div class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1.5 items-center hover:bg-accent/30 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === object.id }" @click="$emit('selectObject', object)">
|
||||
<div v-if="object.operationType !== 'delete'" class="flex items-center gap-2 min-w-0 pl-6">
|
||||
<button v-if="object.children?.length" type="button" class="shrink-0" @click.stop="toggleObjectExpanded(object.id)">
|
||||
<ChevronDown v-if="isObjectExpanded(object.id)" class="w-3.5 h-3.5" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span v-else class="w-3.5 shrink-0" />
|
||||
<input type="checkbox" class="accent-primary shrink-0" :checked="schemaDiffObjectSelectionState(object).checked" :indeterminate="schemaDiffObjectSelectionState(object).indeterminate" @click.stop @change="onObjectCheckboxChange(object, $event)" />
|
||||
<component :is="getObjectIcon(object.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(object.objectKind)" />
|
||||
<span class="text-xs truncate" :class="object.operationType === 'create' ? 'text-green-500' : ''">{{ sourceObjectName(object) }}</span>
|
||||
<span v-if="object.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(object).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(object).length }}</span>
|
||||
</div>
|
||||
<div v-else />
|
||||
|
||||
<input type="checkbox" class="accent-primary shrink-0" :checked="isTypeGroupFullySelected(typeGroup)" :indeterminate="isTypeGroupPartiallySelected(typeGroup)" @click.stop @change="onTypeCheckboxChange(group, typeGroup, $event)" />
|
||||
<div class="flex justify-center">
|
||||
<component :is="operationIcons[object.operationType]" class="w-3.5 h-3.5" :class="operationColors[object.operationType]" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div v-if="object.operationType !== 'create'" class="flex items-center gap-2 min-w-0 pl-6">
|
||||
<button v-if="object.operationType === 'delete' && object.children?.length" type="button" class="shrink-0" @click.stop="toggleObjectExpanded(object.id)">
|
||||
<ChevronDown v-if="isObjectExpanded(object.id)" class="w-3.5 h-3.5" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span v-else-if="object.operationType === 'delete'" class="w-3.5 shrink-0" />
|
||||
<input
|
||||
v-if="object.operationType === 'delete'"
|
||||
type="checkbox"
|
||||
class="accent-primary shrink-0"
|
||||
:checked="schemaDiffObjectSelectionState(object).checked"
|
||||
:indeterminate="schemaDiffObjectSelectionState(object).indeterminate"
|
||||
@click.stop
|
||||
@change="onObjectCheckboxChange(object, $event)"
|
||||
/>
|
||||
<component :is="getObjectIcon(object.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(object.objectKind)" />
|
||||
<span class="text-xs truncate" :class="object.operationType === 'delete' ? 'text-red-500 line-through' : ''">{{ targetObjectName(object) }}</span>
|
||||
<span v-if="object.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(object).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(object).length }}</span>
|
||||
</div>
|
||||
<div v-else />
|
||||
</div>
|
||||
|
||||
<!-- 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.parentName ? `${obj.parentName}.${obj.sourceName}` : obj.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
|
||||
</span>
|
||||
<div v-if="object.children?.length && isObjectExpanded(object.id)" class="border-t border-border/20 bg-muted/10">
|
||||
<div v-for="child in object.children" :key="child.id" class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1 items-center hover:bg-accent/30 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === child.id }" @click="$emit('selectObject', child)">
|
||||
<div v-if="child.operationType !== 'delete'" class="flex items-center gap-2 min-w-0 pl-16">
|
||||
<input type="checkbox" class="accent-primary shrink-0" :checked="child.selected" @click.stop @change="onObjectCheckboxChange(child, $event)" />
|
||||
<component :is="getObjectIcon(child.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(child.objectKind)" />
|
||||
<span class="text-xs truncate">{{ sourceObjectName(child) }}</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- Operation -->
|
||||
<div v-else />
|
||||
<div class="flex justify-center">
|
||||
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5" :class="operationColors[obj.operationType]" />
|
||||
<component :is="operationIcons[child.operationType]" class="w-3.5 h-3.5" :class="operationColors[child.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.parentName ? `${obj.parentName}.${obj.targetName}` : obj.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
|
||||
</span>
|
||||
<div v-if="child.operationType !== 'create'" class="flex items-center gap-2 min-w-0 pl-16">
|
||||
<input v-if="child.operationType === 'delete'" type="checkbox" class="accent-primary shrink-0" :checked="child.selected" @click.stop @change="onObjectCheckboxChange(child, $event)" />
|
||||
<component :is="getObjectIcon(child.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(child.objectKind)" />
|
||||
<span class="text-xs truncate" :class="child.operationType === 'delete' ? 'text-red-500 line-through' : ''">{{ targetObjectName(child) }}</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const panelSource = readFileSync(new URL("../SchemaDiffDdlPanel.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SchemaDiffDdlPanel diff highlighting", () => {
|
||||
it("keeps modified rows height-stable and sticky DDL headers opaque", () => {
|
||||
expect(panelSource).not.toContain("border-yellow-500/40");
|
||||
expect(panelSource).not.toContain("sticky top-0 bg-muted/50");
|
||||
expect(panelSource.match(/sticky top-0 bg-background/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("never applies the focused-row outline to empty padding lines", () => {
|
||||
expect(panelSource).toContain('if (line.isPadding || focusedLineNumber === null) return "";');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../SchemaDiffDialog.vue", import.meta.url), "utf8");
|
||||
const configStepSource = readFileSync(new URL("../SchemaDiffConfigStep.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
it("fits the dialog to its portal instead of the viewport width", () => {
|
||||
@@ -39,4 +40,18 @@ describe("SchemaDiffDialog fullscreen layout", () => {
|
||||
// become reachable via scrolling.
|
||||
expect(dialogSource).toMatch(/<SchemaDiffConfigStep\n[\s\S]*?class="shrink-0"/);
|
||||
});
|
||||
|
||||
it("clears stale result state whenever a new dialog session opens", () => {
|
||||
expect(dialogSource).toContain("function resetComparisonResultState() {");
|
||||
expect(dialogSource).toMatch(/if \(isOpen\) \{\s+resetComparisonResultState\(\);/);
|
||||
expect(dialogSource).toContain("diffObjects.value = [];");
|
||||
expect(dialogSource).toContain("lastDiffResult.value = null;");
|
||||
});
|
||||
|
||||
it("does not restore persisted table matches before source and target identities are ready", () => {
|
||||
expect(configStepSource).toContain('if (!isTableIdentityReady("source")) {');
|
||||
expect(configStepSource).toContain("clearUnavailableTableSelection();");
|
||||
expect(configStepSource).toContain('v-if="restrictTables && canConfigureTableSelection"');
|
||||
expect(configStepSource).toContain("restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { convertToSchemaDiffObjects, groupDiffObjects, type SchemaDiffObject } from "@/lib/schema/schemaDiff";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, params?: { selected: number; total: number }) => (params ? `${key}:${params.selected}/${params.total}` : key) }) }));
|
||||
|
||||
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("SchemaDiffObjectTree", () => {
|
||||
it("renders operation -> table -> field/index selection without a database root", async () => {
|
||||
const groups = groupDiffObjects(
|
||||
convertToSchemaDiffObjects([
|
||||
{
|
||||
type: "modified",
|
||||
objectType: "table",
|
||||
name: "users",
|
||||
columns: [
|
||||
{ type: "modified", name: "email" },
|
||||
{ type: "added", name: "nickname" },
|
||||
],
|
||||
indexes: [{ type: "removed", name: "idx_legacy" }],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const toggles: Array<{ object: SchemaDiffObject; selected: boolean }> = [];
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(SchemaDiffObjectTree, {
|
||||
groups,
|
||||
selectedObjectId: null,
|
||||
onToggleObjectSelection: (object: SchemaDiffObject, selected: boolean) => toggles.push({ object, selected }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
expect(host.textContent).toContain("diff.operationLabel.create");
|
||||
expect(host.textContent).toContain("diff.operationLabel.delete");
|
||||
expect(host.textContent).not.toContain("database");
|
||||
|
||||
const tableExpandButtons = new Set(
|
||||
Array.from(host.querySelectorAll("span"))
|
||||
.filter((element) => element.textContent === "users")
|
||||
.map((element) => element.closest(".grid")?.querySelector("button") as HTMLButtonElement),
|
||||
);
|
||||
for (const button of tableExpandButtons) button.click();
|
||||
await nextTick();
|
||||
|
||||
const tableSides = Array.from(host.querySelectorAll("span"))
|
||||
.filter((element) => element.textContent === "users")
|
||||
.map((element) => element.parentElement)
|
||||
.filter((element): element is HTMLElement => element !== null);
|
||||
expect(tableSides.length).toBeGreaterThan(0);
|
||||
expect(tableSides.every((element) => element.classList.contains("pl-6"))).toBe(true);
|
||||
|
||||
const emailSides = Array.from(host.querySelectorAll("span")).filter((element) => element.textContent === "email");
|
||||
expect(emailSides).toHaveLength(2);
|
||||
expect(emailSides.every((element) => element.parentElement?.classList.contains("pl-16"))).toBe(true);
|
||||
|
||||
const nickname = Array.from(host.querySelectorAll("span")).find((element) => element.textContent === "nickname");
|
||||
expect(nickname).toBeTruthy();
|
||||
const nicknameCheckbox = nickname?.closest(".grid")?.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
nicknameCheckbox.checked = false;
|
||||
nicknameCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(toggles.at(-1)?.object.id).toBe("col-users-nickname");
|
||||
expect(toggles.at(-1)?.selected).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user