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);
|
||||
});
|
||||
});
|
||||
@@ -5702,6 +5702,7 @@ export default {
|
||||
index: "Index",
|
||||
foreignKey: "Foreign Key",
|
||||
trigger: "Trigger",
|
||||
tableOption: "Table options",
|
||||
},
|
||||
noObjectsSelected: "Please select objects to deploy",
|
||||
deploySuccess: "Deploy Successful",
|
||||
|
||||
@@ -5438,6 +5438,7 @@ export default withEnglishFallback({
|
||||
index: "Índice",
|
||||
foreignKey: "Clave foránea",
|
||||
trigger: "Disparador",
|
||||
tableOption: "Opciones de tabla",
|
||||
},
|
||||
noObjectsSelected: "Selecciona objetos para desplegar",
|
||||
deploySuccess: "Despliegue exitoso",
|
||||
|
||||
@@ -5438,6 +5438,7 @@ export default withEnglishFallback({
|
||||
index: "Indice",
|
||||
foreignKey: "Chiave Esterna",
|
||||
trigger: "Trigger",
|
||||
tableOption: "Opzioni tabella",
|
||||
},
|
||||
noObjectsSelected: "Seleziona gli oggetti da distribuire",
|
||||
deploySuccess: "Deploy Riuscito",
|
||||
|
||||
@@ -5445,6 +5445,7 @@ export default withEnglishFallback({
|
||||
index: "インデックス",
|
||||
foreignKey: "外部キー",
|
||||
trigger: "トリガー",
|
||||
tableOption: "テーブルオプション",
|
||||
},
|
||||
noObjectsSelected: "デプロイするオブジェクトを選択してください",
|
||||
deploySuccess: "デプロイ成功",
|
||||
|
||||
@@ -5131,6 +5131,7 @@ export default withEnglishFallback({
|
||||
index: "인덱스",
|
||||
foreignKey: "외래 키",
|
||||
trigger: "트리거",
|
||||
tableOption: "테이블 옵션",
|
||||
},
|
||||
noObjectsSelected: "배포할 객체를 선택하세요",
|
||||
deploySuccess: "배포 성공",
|
||||
|
||||
@@ -5440,6 +5440,7 @@ export default withEnglishFallback({
|
||||
index: "Índice",
|
||||
foreignKey: "Chave Estrangeira",
|
||||
trigger: "Gatilho",
|
||||
tableOption: "Opções da tabela",
|
||||
},
|
||||
noObjectsSelected: "Selecione objetos para implantar",
|
||||
deploySuccess: "Implantação Bem-sucedida",
|
||||
|
||||
@@ -5676,6 +5676,7 @@ export default withEnglishFallback({
|
||||
index: "索引",
|
||||
foreignKey: "外键",
|
||||
trigger: "触发器",
|
||||
tableOption: "表选项",
|
||||
},
|
||||
noObjectsSelected: "请选择要部署的对象",
|
||||
deploySuccess: "部署成功",
|
||||
|
||||
@@ -4697,6 +4697,7 @@ export default withEnglishFallback({
|
||||
index: "索引",
|
||||
foreignKey: "外鍵",
|
||||
trigger: "觸發器",
|
||||
tableOption: "資料表選項",
|
||||
},
|
||||
recentConfigs: "最近配置",
|
||||
selectRecentConfig: "選擇最近配置",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
|
||||
import type { SchemaDiffObject } from "@/lib/schema/schemaDiff";
|
||||
|
||||
const field: SchemaDiffObject = {
|
||||
id: "col-agents-context_window",
|
||||
operationType: "modify",
|
||||
objectKind: "column",
|
||||
name: "context_window",
|
||||
selected: true,
|
||||
parentId: "table-agents",
|
||||
};
|
||||
|
||||
describe("schema diff DDL location", () => {
|
||||
it("finds a moved field independently on both DDL sides", () => {
|
||||
const source = ["CREATE TABLE `agents` (", " `id` bigint NOT NULL,", " `context_window` int NOT NULL", ");"].join("\n");
|
||||
const target = ["CREATE TABLE `agents` (", " `context_window` int DEFAULT NULL,", " `id` bigint NOT NULL", ");"].join("\n");
|
||||
|
||||
expect(findSchemaDiffDdlLineNumber(source, field, "source")).toBe(3);
|
||||
expect(findSchemaDiffDdlLineNumber(target, field, "target")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null on the side where a created field does not exist", () => {
|
||||
const createdField = { ...field, operationType: "create" as const, name: "next_step_prompt", sourceName: "next_step_prompt", targetName: undefined };
|
||||
const source = ["CREATE TABLE `agents` (", " `next_step_prompt` longtext", ");"].join("\n");
|
||||
const target = ["CREATE TABLE `agents` (", " `id` bigint NOT NULL", ");"].join("\n");
|
||||
|
||||
expect(findSchemaDiffDdlLineNumber(source, createdField, "source")).toBe(2);
|
||||
expect(findSchemaDiffDdlLineNumber(target, createdField, "target")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSchemaDiffHighlightSegments } from "@/lib/schema/schemaDiffHighlight";
|
||||
|
||||
describe("schema diff character highlighting", () => {
|
||||
it("covers the full span between disjoint type and default changes", () => {
|
||||
const source = " `next_step_prompt` longtext COLLATE utf8mb4_unicode_ci,";
|
||||
const target = " `next_step_prompt` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,";
|
||||
const result = buildSchemaDiffHighlightSegments(source, target);
|
||||
|
||||
expect(result.sourceSegments.map((segment) => segment.text).join("")).toBe(source);
|
||||
expect(result.targetSegments.map((segment) => segment.text).join("")).toBe(target);
|
||||
expect(result.sourceSegments.filter((segment) => segment.changed).map((segment) => segment.text)).toEqual(["longtext COLLATE utf8mb4_unicode_ci"]);
|
||||
expect(result.targetSegments.filter((segment) => segment.changed).map((segment) => segment.text)).toEqual(["varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL"]);
|
||||
});
|
||||
|
||||
it("does not leave DEFAULT or coincidentally matched characters unhighlighted inside a changed clause", () => {
|
||||
const source = " `context_window` int(11) NOT NULL DEFAULT '262144' COMMENT '模型上下文窗口大小 (token),默认',";
|
||||
const target = " `context_window` int(11) DEFAULT NULL,";
|
||||
const result = buildSchemaDiffHighlightSegments(source, target);
|
||||
|
||||
expect(result.sourceSegments.filter((segment) => segment.changed).map((segment) => segment.text)).toEqual(["NOT NULL DEFAULT '262144' COMMENT '模型上下文窗口大小 (token),默认'"]);
|
||||
expect(result.targetSegments.filter((segment) => segment.changed).map((segment) => segment.text)).toEqual(["DEFAULT NULL"]);
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,7 @@ export const listExtensions = forward("listExtensions");
|
||||
export const listAvailableExtensions = forward("listAvailableExtensions");
|
||||
export const prepareSchemaDiff = forward("prepareSchemaDiff");
|
||||
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
|
||||
export const generateSchemaSyncPlan = forward("generateSchemaSyncPlan");
|
||||
export const listDialectDataTypes = forward("listDialectDataTypes");
|
||||
|
||||
// Docs
|
||||
|
||||
@@ -51,7 +51,7 @@ import { BackendErrorException, type BackendError } from "@/lib/backend/errorUti
|
||||
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
|
||||
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSqlPlan, SelectedSchemaDiffInput, GenerateSchemaSyncPlanOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
|
||||
import type { AiChatSelectionState, AiEffortCapability } from "@/types/ai";
|
||||
@@ -959,6 +959,13 @@ export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: Da
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateSchemaSyncPlan(input: SelectedSchemaDiffInput, options: GenerateSchemaSyncPlanOptions): Promise<SchemaSyncSqlPlan> {
|
||||
return post("/api/schema-diff/generate-sync-plan", {
|
||||
...input,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listFunctions(connectionId: string, database: string, schema: string): Promise<FunctionInfo[]> {
|
||||
return get(`/api/schema/functions?${qs({ connection_id: connectionId, database, schema })}`);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ import type {
|
||||
} from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSqlPlan, SelectedSchemaDiffInput, GenerateSchemaSyncPlanOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
|
||||
@@ -1865,6 +1865,13 @@ export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: Da
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateSchemaSyncPlan(input: SelectedSchemaDiffInput, options: GenerateSchemaSyncPlanOptions): Promise<SchemaSyncSqlPlan> {
|
||||
return invoke("generate_schema_sync_plan", {
|
||||
...input,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listFunctions(connectionId: string, database: string, schema: string): Promise<FunctionInfo[]> {
|
||||
return invoke("list_functions", { connectionId, database, schema });
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ function nameSimilarity(a: string, b: string): number {
|
||||
}
|
||||
|
||||
export interface ColumnDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
type: "added" | "removed" | "modified" | "renamed";
|
||||
name: string;
|
||||
source?: ColumnInfo;
|
||||
target?: ColumnInfo;
|
||||
@@ -199,6 +199,8 @@ export interface TableSchemaDetail {
|
||||
export interface FieldMappingEntry {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
paramStrategy?: "preserve" | "strip" | "custom";
|
||||
customParams?: string;
|
||||
}
|
||||
|
||||
export interface SchemaDiffPreparationOptions {
|
||||
@@ -289,6 +291,22 @@ export interface SchemaDiffPreparation {
|
||||
dependencyGraph?: DependencyGraph;
|
||||
}
|
||||
|
||||
export interface SchemaSyncSqlPlan {
|
||||
syncSql: string;
|
||||
rollbackSyncSql?: string;
|
||||
rollbackCompleteness: RollbackCompleteness;
|
||||
missingRollbackObjects: MissingRollbackObject[];
|
||||
}
|
||||
|
||||
export interface GenerateSchemaSyncPlanOptions {
|
||||
databaseType: DatabaseType;
|
||||
targetSchema?: string;
|
||||
cascadeDelete?: boolean;
|
||||
sourceDialect?: string;
|
||||
fieldMappings?: FieldMappingEntry[];
|
||||
enableRollback?: boolean;
|
||||
}
|
||||
|
||||
const MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb", "sundb", "databend", "gbase"]);
|
||||
|
||||
export function schemaDiffDeployTargetSchema(databaseType: DatabaseType | undefined, targetDatabase: string, targetSchema?: string): string | undefined {
|
||||
@@ -305,7 +323,7 @@ export function schemaDiffDeployTargetSchema(databaseType: DatabaseType | undefi
|
||||
|
||||
// Unified object type for UI display
|
||||
export type DiffOperationType = "modify" | "create" | "delete" | "none";
|
||||
export type DiffObjectKind = "table" | "view" | "function" | "sequence" | "rule" | "owner" | "column" | "index" | "trigger" | "foreignKey";
|
||||
export type DiffObjectKind = "table" | "view" | "function" | "sequence" | "rule" | "owner" | "column" | "index" | "trigger" | "foreignKey" | "tableOption";
|
||||
|
||||
export interface SchemaDiffObject {
|
||||
id: string;
|
||||
@@ -333,6 +351,30 @@ export interface SchemaDiffObject {
|
||||
};
|
||||
}
|
||||
|
||||
function tableObjectId(tableName: string): string {
|
||||
return `table-${tableName}`;
|
||||
}
|
||||
|
||||
function columnObjectId(tableName: string, columnName: string): string {
|
||||
return `col-${tableName}-${columnName}`;
|
||||
}
|
||||
|
||||
function indexObjectId(tableName: string, indexName: string): string {
|
||||
return `idx-${tableName}-${indexName}`;
|
||||
}
|
||||
|
||||
function foreignKeyObjectId(tableName: string, foreignKeyName: string): string {
|
||||
return `fk-${tableName}-${foreignKeyName}`;
|
||||
}
|
||||
|
||||
function triggerObjectId(tableName: string, triggerName: string): string {
|
||||
return `trg-${tableName}-${triggerName}`;
|
||||
}
|
||||
|
||||
function tableOptionObjectId(tableName: string): string {
|
||||
return `table-option-${tableName}`;
|
||||
}
|
||||
|
||||
export interface SchemaDiffGroup {
|
||||
operationType: DiffOperationType;
|
||||
label: string;
|
||||
@@ -534,9 +576,79 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
|
||||
const opType = getOperationType(diff.type);
|
||||
const isRenamed = diff.type === "renamed";
|
||||
const newName = isRenamed && renameCandidates ? (renameCandidates.find((rc) => rc.sourceName === diff.name)?.targetName ?? diff.name) : undefined;
|
||||
const tableId = tableObjectId(diff.name);
|
||||
const children: SchemaDiffObject[] =
|
||||
opType === "modify"
|
||||
? [
|
||||
...(diff.columns?.map((column) => ({
|
||||
id: columnObjectId(diff.name, column.name),
|
||||
operationType: getOperationType(column.type),
|
||||
objectKind: "column" as DiffObjectKind,
|
||||
name: column.name,
|
||||
sourceName: column.type === "added" ? undefined : column.name,
|
||||
targetName: column.type === "removed" ? undefined : column.name,
|
||||
selected: true,
|
||||
changes: column.changes,
|
||||
parentId: tableId,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(diff.indexes?.map((index) => ({
|
||||
id: indexObjectId(diff.name, index.name),
|
||||
// A modified index is one DROP + CREATE deploy unit.
|
||||
operationType: index.type === "modified" ? ("delete" as DiffOperationType) : getOperationType(index.type),
|
||||
objectKind: "index" as DiffObjectKind,
|
||||
name: index.name,
|
||||
sourceName: index.type === "added" ? undefined : index.name,
|
||||
targetName: index.type === "removed" ? undefined : index.name,
|
||||
selected: true,
|
||||
changes: index.changes,
|
||||
parentId: tableId,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(diff.foreignKeys?.map((foreignKey) => ({
|
||||
id: foreignKeyObjectId(diff.name, foreignKey.name),
|
||||
// Modified foreign keys are also dropped before they are recreated.
|
||||
operationType: foreignKey.type === "modified" ? ("delete" as DiffOperationType) : getOperationType(foreignKey.type),
|
||||
objectKind: "foreignKey" as DiffObjectKind,
|
||||
name: foreignKey.name,
|
||||
sourceName: foreignKey.type === "added" ? undefined : foreignKey.name,
|
||||
targetName: foreignKey.type === "removed" ? undefined : foreignKey.name,
|
||||
selected: true,
|
||||
changes: foreignKey.changes,
|
||||
parentId: tableId,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(diff.triggers?.map((trigger) => ({
|
||||
id: triggerObjectId(diff.name, trigger.name),
|
||||
operationType: getOperationType(trigger.type),
|
||||
objectKind: "trigger" as DiffObjectKind,
|
||||
name: trigger.name,
|
||||
sourceName: trigger.type === "added" ? undefined : trigger.name,
|
||||
targetName: trigger.type === "removed" ? undefined : trigger.name,
|
||||
selected: true,
|
||||
changes: trigger.changes,
|
||||
parentId: tableId,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(diff.sourceTableComment !== diff.targetTableComment
|
||||
? [
|
||||
{
|
||||
id: tableOptionObjectId(diff.name),
|
||||
operationType: "modify" as DiffOperationType,
|
||||
objectKind: "tableOption" as DiffObjectKind,
|
||||
name: "tableOption",
|
||||
selected: true,
|
||||
changes: [`comment: ${diff.targetTableComment ?? ""} -> ${diff.sourceTableComment ?? ""}`],
|
||||
parentId: tableId,
|
||||
parentName: diff.name,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
|
||||
const obj: SchemaDiffObject = {
|
||||
id: `table-${diff.name}`,
|
||||
id: tableId,
|
||||
operationType: opType,
|
||||
objectKind: diff.objectType === "view" ? "view" : "table",
|
||||
name: diff.name,
|
||||
@@ -548,59 +660,7 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
|
||||
deploySql: isRenamed && newName ? (diff.objectType === "view" ? `ALTER VIEW ${diff.name} RENAME TO ${newName};` : `RENAME TABLE ${diff.name} TO ${newName};`) : diff.syncSql,
|
||||
changes: diff.columns?.flatMap((c) => c.changes || []),
|
||||
renameMetadata: isRenamed && newName ? { confirmed: true, sourceName: diff.name, targetName: newName, score: renameCandidates?.find((rc) => rc.sourceName === diff.name)?.score } : undefined,
|
||||
children: [
|
||||
...(diff.columns?.map((c) => ({
|
||||
id: `col-${diff.name}-${c.name}`,
|
||||
operationType: getOperationType(c.type),
|
||||
objectKind: "column" 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,
|
||||
parentId: `table-${diff.name}`,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(diff.indexes?.map((i) => ({
|
||||
id: `idx-${diff.name}-${i.name}`,
|
||||
// A modified index is implemented as DROP + CREATE by the backend.
|
||||
// Surface the destructive half in the delete group instead of hiding it under table modification.
|
||||
operationType: i.type === "modified" ? ("delete" as DiffOperationType) : 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,
|
||||
parentId: `table-${diff.name}`,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(diff.foreignKeys?.map((f) => ({
|
||||
id: `fk-${diff.name}-${f.name}`,
|
||||
// Modified foreign keys are also dropped before they are recreated.
|
||||
operationType: f.type === "modified" ? ("delete" as DiffOperationType) : 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,
|
||||
parentId: `table-${diff.name}`,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
...(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,
|
||||
parentId: `table-${diff.name}`,
|
||||
parentName: diff.name,
|
||||
})) || []),
|
||||
],
|
||||
children: children.length > 0 ? children : undefined,
|
||||
};
|
||||
objects.push(obj);
|
||||
}
|
||||
@@ -684,6 +744,64 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
|
||||
return objects;
|
||||
}
|
||||
|
||||
export interface SelectedSchemaDiffInput {
|
||||
diffs: TableDiff[];
|
||||
functionDiffs: FunctionDiff[];
|
||||
sequenceDiffs: SequenceDiff[];
|
||||
ruleDiffs: RuleDiff[];
|
||||
ownerDiffs: OwnerDiff[];
|
||||
}
|
||||
|
||||
/** Project the original structured diff onto the current result-tree selection. */
|
||||
export function selectSchemaDiffInput(result: SchemaDiffPreparation, objects: SchemaDiffObject[]): SelectedSchemaDiffInput {
|
||||
const selectedIds = new Set(
|
||||
flattenSchemaDiffObjects(objects)
|
||||
.filter((object) => object.selected && object.operationType !== "none")
|
||||
.map((object) => object.id),
|
||||
);
|
||||
|
||||
const diffs = result.diffs.flatMap((diff): TableDiff[] => {
|
||||
const tableObject = findSchemaDiffObject(objects, tableObjectId(diff.name));
|
||||
if (!tableObject) return [];
|
||||
|
||||
const isAtomic = diff.type !== "modified" || diff.objectType === "view" || !tableObject.children?.length;
|
||||
if (isAtomic) {
|
||||
return tableObject.selected ? [{ ...diff, syncSql: undefined }] : [];
|
||||
}
|
||||
|
||||
const columns = diff.columns?.filter((column) => selectedIds.has(columnObjectId(diff.name, column.name))) ?? [];
|
||||
const indexes = diff.indexes?.filter((index) => selectedIds.has(indexObjectId(diff.name, index.name))) ?? [];
|
||||
const foreignKeys = diff.foreignKeys?.filter((foreignKey) => selectedIds.has(foreignKeyObjectId(diff.name, foreignKey.name))) ?? [];
|
||||
const triggers = diff.triggers?.filter((trigger) => selectedIds.has(triggerObjectId(diff.name, trigger.name))) ?? [];
|
||||
const includeTableOptions = selectedIds.has(tableOptionObjectId(diff.name));
|
||||
|
||||
if (columns.length === 0 && indexes.length === 0 && foreignKeys.length === 0 && triggers.length === 0 && !includeTableOptions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...diff,
|
||||
columns,
|
||||
indexes,
|
||||
foreignKeys,
|
||||
triggers,
|
||||
sourceTableComment: includeTableOptions ? diff.sourceTableComment : undefined,
|
||||
targetTableComment: includeTableOptions ? diff.targetTableComment : undefined,
|
||||
syncSql: undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
diffs,
|
||||
functionDiffs: (result.functionDiffs ?? []).filter((diff) => selectedIds.has(`func-${diff.name}-${diff.source?.arguments || diff.target?.arguments || ""}`)),
|
||||
sequenceDiffs: (result.sequenceDiffs ?? []).filter((diff) => selectedIds.has(`seq-${diff.name}`)),
|
||||
ruleDiffs: (result.ruleDiffs ?? []).filter((diff) => selectedIds.has(`rule-${diff.name}`)),
|
||||
ownerDiffs: (result.ownerDiffs ?? []).filter((diff) => selectedIds.has(`owner-${diff.objectName}`)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDeploySqlForObjects(objects: SchemaDiffObject[]): string {
|
||||
const selected = objects.filter((o) => {
|
||||
return o.selected && o.operationType !== "none" && !o.parentId;
|
||||
@@ -894,31 +1012,134 @@ export function setSchemaDiffObjectSelected(objects: SchemaDiffObject[], objectI
|
||||
const object = findSchemaDiffObject(objects, objectId);
|
||||
if (!object) return false;
|
||||
|
||||
// Table sync SQL is generated as one deploy unit. A child toggle therefore
|
||||
// selects or clears its whole table so hidden sibling DDL cannot still run.
|
||||
const selectionOwner = object.parentId ? findSchemaDiffObject(objects, object.parentId) : object;
|
||||
if (!selectionOwner) return false;
|
||||
|
||||
const applySelection = (target: SchemaDiffObject) => {
|
||||
target.selected = selected;
|
||||
for (const child of target.children ?? []) applySelection(child);
|
||||
};
|
||||
applySelection(selectionOwner);
|
||||
applySelection(object);
|
||||
|
||||
if (object.parentId) {
|
||||
const parent = findSchemaDiffObject(objects, object.parentId);
|
||||
if (parent) {
|
||||
const children = parent.children?.filter((child) => child.operationType !== "none") ?? [];
|
||||
parent.selected = children.length > 0 && children.every((child) => child.selected);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Apply one leaf selection while keeping generated DDL dependencies executable. */
|
||||
export function setSchemaDiffObjectSelectedWithDependencies(objects: SchemaDiffObject[], result: SchemaDiffPreparation, objectId: string, selected: boolean): boolean {
|
||||
const changed = setSchemaDiffObjectSelected(objects, objectId, selected);
|
||||
const initialObject = findSchemaDiffObject(objects, objectId);
|
||||
if (!changed || !initialObject?.parentId) return changed;
|
||||
|
||||
const visited = new Set<string>();
|
||||
const apply = (id: string, value: boolean) => {
|
||||
if (visited.has(`${id}:${value}`)) return;
|
||||
visited.add(`${id}:${value}`);
|
||||
|
||||
const object = findSchemaDiffObject(objects, id);
|
||||
if (!object?.parentId) return;
|
||||
const tableObject = findSchemaDiffObject(objects, object.parentId);
|
||||
const tableDiff = result.diffs.find((diff) => diff.name === tableObject?.name);
|
||||
if (!tableObject || !tableDiff) return;
|
||||
setSchemaDiffObjectSelected(objects, id, value);
|
||||
|
||||
const child = (kind: DiffObjectKind, name: string) => tableObject.children?.find((candidate) => candidate.objectKind === kind && candidate.name === name);
|
||||
const applyColumn = (name: string, value: boolean) => {
|
||||
const columnDiff = tableDiff.columns?.find((column) => column.name === name);
|
||||
const columnObject = child("column", name);
|
||||
if (!columnDiff || !columnObject) return;
|
||||
if (value && !["added", "renamed"].includes(columnDiff.type)) return;
|
||||
apply(columnObject.id, value);
|
||||
};
|
||||
|
||||
if (object.objectKind === "column") {
|
||||
const columnDiff = tableDiff.columns?.find((column) => column.name === object.name);
|
||||
if (!columnDiff) return;
|
||||
|
||||
if (value && columnDiff.type === "added" && columnDiff.addPosition && typeof columnDiff.addPosition === "object") {
|
||||
applyColumn(columnDiff.addPosition.after, true);
|
||||
}
|
||||
|
||||
if (value && columnDiff.type === "removed") {
|
||||
for (const index of tableDiff.indexes ?? []) {
|
||||
if (!["removed", "modified"].includes(index.type) || !index.target?.columns.includes(columnDiff.name)) continue;
|
||||
const indexObject = child("index", index.name);
|
||||
if (indexObject) apply(indexObject.id, true);
|
||||
}
|
||||
for (const foreignKey of tableDiff.foreignKeys ?? []) {
|
||||
if (!["removed", "modified"].includes(foreignKey.type) || foreignKey.target?.column !== columnDiff.name) continue;
|
||||
const foreignKeyObject = child("foreignKey", foreignKey.name);
|
||||
if (foreignKeyObject) apply(foreignKeyObject.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!value && ["added", "renamed"].includes(columnDiff.type)) {
|
||||
for (const dependentColumn of tableDiff.columns ?? []) {
|
||||
if (dependentColumn.type !== "added" || !dependentColumn.addPosition || typeof dependentColumn.addPosition !== "object" || dependentColumn.addPosition.after !== columnDiff.name) continue;
|
||||
const dependentObject = child("column", dependentColumn.name);
|
||||
if (dependentObject) apply(dependentObject.id, false);
|
||||
}
|
||||
for (const index of tableDiff.indexes ?? []) {
|
||||
const sourceColumns = [...(index.source?.columns ?? []), ...(index.source?.included_columns ?? [])];
|
||||
if (!["added", "modified"].includes(index.type) || !sourceColumns.includes(columnDiff.name)) continue;
|
||||
const indexObject = child("index", index.name);
|
||||
if (indexObject) apply(indexObject.id, false);
|
||||
}
|
||||
for (const foreignKey of tableDiff.foreignKeys ?? []) {
|
||||
if (!["added", "modified"].includes(foreignKey.type) || foreignKey.source?.column !== columnDiff.name) continue;
|
||||
const foreignKeyObject = child("foreignKey", foreignKey.name);
|
||||
if (foreignKeyObject) apply(foreignKeyObject.id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value && object.objectKind === "index") {
|
||||
const index = tableDiff.indexes?.find((candidate) => candidate.name === object.name);
|
||||
for (const columnName of [...(index?.source?.columns ?? []), ...(index?.source?.included_columns ?? [])]) {
|
||||
applyColumn(columnName, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (value && object.objectKind === "foreignKey") {
|
||||
const foreignKey = tableDiff.foreignKeys?.find((candidate) => candidate.name === object.name);
|
||||
if (foreignKey?.source?.column) applyColumn(foreignKey.source.column, true);
|
||||
}
|
||||
};
|
||||
|
||||
visited.clear();
|
||||
apply(objectId, selected);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function selectedSchemaDiffObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
|
||||
return objects.filter((object) => object.selected && object.operationType !== "none");
|
||||
return objects.flatMap((object) => {
|
||||
const children = object.children?.filter((child) => child.operationType !== "none") ?? [];
|
||||
if (children.length > 0) return children.filter((child) => child.selected);
|
||||
return object.selected && object.operationType !== "none" ? [object] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function schemaDiffSelectionOwnerId(object: SchemaDiffObject): string {
|
||||
return object.parentId ?? object.id;
|
||||
export function schemaDiffSelectionTargets(object: SchemaDiffObject): SchemaDiffObject[] {
|
||||
const children = object.children?.filter((child) => child.operationType !== "none") ?? [];
|
||||
return children.length > 0 ? children : [object];
|
||||
}
|
||||
|
||||
export function schemaDiffObjectSelectionState(object: SchemaDiffObject): { checked: boolean; indeterminate: boolean } {
|
||||
const targets = schemaDiffSelectionTargets(object);
|
||||
const selectedCount = targets.filter((target) => target.selected).length;
|
||||
return {
|
||||
checked: targets.length > 0 && selectedCount === targets.length,
|
||||
indeterminate: selectedCount > 0 && selectedCount < targets.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSchemaDiffOperations(objects: SchemaDiffObject[]): Record<DiffOperationType, number> {
|
||||
const counts: Record<DiffOperationType, number> = { create: 0, modify: 0, delete: 0, none: 0 };
|
||||
for (const object of schemaDiffReviewObjects(objects)) {
|
||||
if (object.selected && object.operationType !== "none") counts[object.operationType]++;
|
||||
for (const object of selectedSchemaDiffObjects(objects)) {
|
||||
counts[object.operationType]++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -944,6 +1165,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
index: [],
|
||||
foreignKey: [],
|
||||
trigger: [],
|
||||
tableOption: [],
|
||||
},
|
||||
create: {
|
||||
table: [],
|
||||
@@ -956,6 +1178,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
index: [],
|
||||
foreignKey: [],
|
||||
trigger: [],
|
||||
tableOption: [],
|
||||
},
|
||||
delete: {
|
||||
table: [],
|
||||
@@ -968,6 +1191,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
index: [],
|
||||
foreignKey: [],
|
||||
trigger: [],
|
||||
tableOption: [],
|
||||
},
|
||||
none: {
|
||||
table: [],
|
||||
@@ -980,6 +1204,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
index: [],
|
||||
foreignKey: [],
|
||||
trigger: [],
|
||||
tableOption: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -990,7 +1215,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
const order: DiffOperationType[] = ["modify", "create", "delete", "none"];
|
||||
return order.map((opType) => {
|
||||
const typeGroups: ObjectTypeGroup[] = [];
|
||||
const kinds: DiffObjectKind[] = ["table", "view", "function", "sequence", "rule", "owner", "column", "index", "foreignKey", "trigger"];
|
||||
const kinds: DiffObjectKind[] = ["table", "view", "function", "sequence", "rule", "owner", "column", "index", "foreignKey", "trigger", "tableOption"];
|
||||
|
||||
for (const kind of kinds) {
|
||||
const objs = groups[opType][kind];
|
||||
@@ -1000,17 +1225,18 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
label: getObjectTypeLabel(kind),
|
||||
objects: objs,
|
||||
expanded: true,
|
||||
selectedCount: objs.filter((o) => o.selected).length,
|
||||
selectedCount: objs.flatMap(schemaDiffSelectionTargets).filter((object) => object.selected).length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allObjects = Object.values(groups[opType]).flat();
|
||||
const selectionTargets = allObjects.flatMap(schemaDiffSelectionTargets);
|
||||
return {
|
||||
operationType: opType,
|
||||
label: getOperationLabel(opType),
|
||||
count: allObjects.length,
|
||||
selectedCount: allObjects.filter((o) => o.selected).length,
|
||||
count: selectionTargets.length,
|
||||
selectedCount: selectionTargets.filter((object) => object.selected).length,
|
||||
expanded: opType !== "none",
|
||||
typeGroups,
|
||||
};
|
||||
@@ -1019,22 +1245,27 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
|
||||
|
||||
function schemaDiffReviewObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
|
||||
return objects.flatMap((object) => {
|
||||
if (object.operationType !== "modify") return [object];
|
||||
const children = object.children?.filter((child) => child.operationType !== "none") ?? [];
|
||||
if (object.operationType !== "modify" || children.length === 0) return [object];
|
||||
|
||||
const destructiveChildren = flattenSchemaDiffObjects(object.children ?? []).filter((child) => child.operationType === "delete");
|
||||
if (destructiveChildren.length === 0) return [object];
|
||||
|
||||
const deleteReviewObject: SchemaDiffObject = {
|
||||
...object,
|
||||
id: `delete-risk-${object.id}`,
|
||||
operationType: "delete",
|
||||
sourceName: undefined,
|
||||
targetName: object.targetName ?? object.name,
|
||||
children: undefined,
|
||||
parentId: object.id,
|
||||
changes: destructiveChildren.flatMap((child) => child.changes ?? []),
|
||||
};
|
||||
return [object, deleteReviewObject];
|
||||
const operationOrder: DiffOperationType[] = ["modify", "create", "delete"];
|
||||
return operationOrder.flatMap((operationType): SchemaDiffObject[] => {
|
||||
const operationChildren = children.filter((child) => child.operationType === operationType);
|
||||
if (operationChildren.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
...object,
|
||||
id: `review-${operationType}-${object.id}`,
|
||||
operationType,
|
||||
selected: operationChildren.every((child) => child.selected),
|
||||
sourceName: operationType === "delete" ? undefined : object.sourceName,
|
||||
targetName: operationType === "create" ? undefined : object.targetName,
|
||||
children: operationChildren,
|
||||
parentId: object.id,
|
||||
changes: operationChildren.flatMap((child) => child.changes ?? []),
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1060,6 +1291,8 @@ function getObjectTypeLabel(kind: DiffObjectKind): string {
|
||||
return "diff.objectKindLabel.foreignKey";
|
||||
case "trigger":
|
||||
return "diff.objectKindLabel.trigger";
|
||||
case "tableOption":
|
||||
return "diff.objectKindLabel.tableOption";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SchemaDiffObject } from "@/lib/schema/schemaDiff";
|
||||
|
||||
function readSqlIdentifier(value: string): string | null {
|
||||
const input = value.trimStart();
|
||||
const opening = input[0];
|
||||
const closing = opening === "[" ? "]" : opening;
|
||||
|
||||
if (opening === "`" || opening === '"' || opening === "[") {
|
||||
let identifier = "";
|
||||
for (let index = 1; index < input.length; index++) {
|
||||
if (input[index] !== closing) {
|
||||
identifier += input[index];
|
||||
continue;
|
||||
}
|
||||
if (input[index + 1] === closing) {
|
||||
identifier += closing;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return input.match(/^([A-Za-z_][A-Za-z0-9_$#@]*)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function identifierAfterKeyword(line: string, keyword: RegExp): string | null {
|
||||
const trimmed = line.trimStart();
|
||||
const match = trimmed.match(keyword);
|
||||
return match ? readSqlIdentifier(trimmed.slice(match[0].length)) : null;
|
||||
}
|
||||
|
||||
function lineMatchesObject(line: string, object: SchemaDiffObject, objectName: string): boolean {
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
switch (object.objectKind) {
|
||||
case "column":
|
||||
return readSqlIdentifier(trimmed) === objectName;
|
||||
case "index":
|
||||
if (objectName.toUpperCase() === "PRIMARY" && /^PRIMARY\s+KEY\b/i.test(trimmed)) return true;
|
||||
return identifierAfterKeyword(trimmed, /^(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?(?:KEY|INDEX)\s+/i) === objectName || identifierAfterKeyword(trimmed, /^CREATE\s+(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?INDEX\s+/i) === objectName;
|
||||
case "foreignKey":
|
||||
return identifierAfterKeyword(trimmed, /^CONSTRAINT\s+/i) === objectName;
|
||||
case "tableOption":
|
||||
return /^\)\s*/.test(trimmed) || /\b(?:ENGINE|CHARSET|COLLATE|COMMENT)\s*=/i.test(trimmed);
|
||||
case "table":
|
||||
case "view":
|
||||
return /^CREATE\s+/i.test(trimmed);
|
||||
default:
|
||||
return trimmed.includes(objectName);
|
||||
}
|
||||
}
|
||||
|
||||
export function findSchemaDiffDdlLineNumber(ddl: string, object: SchemaDiffObject, side: "source" | "target"): number | null {
|
||||
if (!ddl) return null;
|
||||
const objectName = (side === "source" ? object.sourceName : object.targetName) ?? object.name;
|
||||
const lines = ddl.replace(/\r\n?/g, "\n").split("\n");
|
||||
const index = lines.findIndex((line) => lineMatchesObject(line, object, objectName));
|
||||
return index >= 0 ? index + 1 : null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { diffChars } from "diff";
|
||||
|
||||
export interface SchemaDiffHighlightSegment {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaDiffHighlightSegments {
|
||||
sourceSegments: SchemaDiffHighlightSegment[];
|
||||
targetSegments: SchemaDiffHighlightSegment[];
|
||||
}
|
||||
|
||||
export function buildSchemaDiffHighlightSegments(source: string, target: string): SchemaDiffHighlightSegments {
|
||||
const sourceSegments: SchemaDiffHighlightSegment[] = [];
|
||||
const targetSegments: SchemaDiffHighlightSegment[] = [];
|
||||
const changes = diffChars(source, target);
|
||||
const firstChangedIndex = changes.findIndex((change) => change.added || change.removed);
|
||||
let lastChangedIndex = -1;
|
||||
for (let index = changes.length - 1; index >= 0; index--) {
|
||||
if (changes[index].added || changes[index].removed) {
|
||||
lastChangedIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const append = (segments: SchemaDiffHighlightSegment[], text: string, changed: boolean) => {
|
||||
if (!text) return;
|
||||
const previous = segments[segments.length - 1];
|
||||
if (previous?.changed === changed) previous.text += text;
|
||||
else segments.push({ text, changed });
|
||||
};
|
||||
|
||||
for (const [index, change] of changes.entries()) {
|
||||
if (change.removed) {
|
||||
append(sourceSegments, change.value, true);
|
||||
} else if (change.added) {
|
||||
append(targetSegments, change.value, true);
|
||||
} else {
|
||||
const betweenChanges = firstChangedIndex >= 0 && index > firstChangedIndex && index < lastChangedIndex;
|
||||
append(sourceSegments, change.value, betweenChanges);
|
||||
append(targetSegments, change.value, betweenChanges);
|
||||
}
|
||||
}
|
||||
|
||||
return { sourceSegments, targetSegments };
|
||||
}
|
||||
Reference in new issue
Block a user