feat(schema-diff): select fields and indexes for synchronization

This commit is contained in:
zhangyc authored and GitHub committed 2026-08-23 12:24:47 +08:00
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);
});
});
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -5445,6 +5445,7 @@ export default withEnglishFallback({
index: "インデックス",
foreignKey: "外部キー",
trigger: "トリガー",
tableOption: "テーブルオプション",
},
noObjectsSelected: "デプロイするオブジェクトを選択してください",
deploySuccess: "デプロイ成功",
+1
View File
@@ -5131,6 +5131,7 @@ export default withEnglishFallback({
index: "인덱스",
foreignKey: "외래 키",
trigger: "트리거",
tableOption: "테이블 옵션",
},
noObjectsSelected: "배포할 객체를 선택하세요",
deploySuccess: "배포 성공",
+1
View File
@@ -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",
+1
View File
@@ -5676,6 +5676,7 @@ export default withEnglishFallback({
index: "索引",
foreignKey: "外键",
trigger: "触发器",
tableOption: "表选项",
},
noObjectsSelected: "请选择要部署的对象",
deploySuccess: "部署成功",
+1
View File
@@ -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"]);
});
});
+1
View File
@@ -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
+8 -1
View File
@@ -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 })}`);
}
+8 -1
View File
@@ -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 });
}
+319 -86
View File
@@ -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 };
}