fix(schema-diff): guard and review destructive DDL

This commit is contained in:
zhangyc authored and GitHub committed 2026-08-13 12:42:51 +08:00
1 parent e8156ab411
commit c51abd2178
22 files changed
+1205 -175

No files matched your search

@@ -7,6 +7,7 @@ export interface DiffLine {
content: string;
lineNumber: number | null;
isPadding: boolean;
comparisonContent?: string;
}
export interface DiffHunk {
@@ -23,6 +24,57 @@ export interface DiffHunk {
const SIMILARITY_THRESHOLD = 0.3;
const ALIGN_WINDOW = 3;
const MAX_GLOBAL_ALIGNMENT_CELLS = 10_000;
const ALIGN_DELETE = 1;
const ALIGN_INSERT = 2;
const ALIGN_MODIFY = 3;
const SQL_LINE_KEYWORDS = new Set([
"add",
"alter",
"as",
"begin",
"check",
"cluster",
"comment",
"constraint",
"create",
"delete",
"distributed",
"drop",
"else",
"end",
"engine",
"foreign",
"from",
"group",
"having",
"if",
"index",
"insert",
"join",
"key",
"on",
"order",
"partition",
"primary",
"references",
"return",
"returns",
"select",
"set",
"settings",
"tablespace",
"then",
"union",
"unique",
"update",
"using",
"values",
"when",
"where",
"with",
]);
function splitLines(value: string): string[] {
const lines = value.split("\n");
@@ -68,15 +120,17 @@ function collectSameKindChanges(changes: Change[], startIdx: number, kind: "adde
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
let previous = Array.from({ length: a.length + 1 }, (_, index) => index);
for (let i = 1; i <= b.length; i++) {
const current = [i];
for (let j = 1; j <= a.length; j++) {
matrix[i][j] = b.charAt(i - 1) === a.charAt(j - 1) ? matrix[i - 1][j - 1] : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
current[j] = b.charAt(i - 1) === a.charAt(j - 1) ? previous[j - 1] : Math.min(previous[j - 1] + 1, current[j - 1] + 1, previous[j] + 1);
}
previous = current;
}
return matrix[b.length][a.length];
return previous[a.length];
}
function computeSimilarity(a: string, b: string): number {
@@ -89,67 +143,250 @@ function computeSimilarity(a: string, b: string): number {
type AlignedItem = { type: "modify"; left: string; right: string } | { type: "delete"; left: string } | { type: "insert"; right: string };
type SqlIdentifier = {
value: string;
rest: string;
quoted: boolean;
};
function readSqlIdentifier(value: string): SqlIdentifier | null {
const input = value.trimStart();
const opening = input[0];
const closing = opening === "[" ? "]" : opening;
if (opening === "`" || opening === '"' || opening === "[") {
let identifier = "";
for (let i = 1; i < input.length; i++) {
if (input[i] !== closing) {
identifier += input[i];
continue;
}
if (input[i + 1] === closing) {
identifier += closing;
i++;
continue;
}
return { value: identifier, rest: input.slice(i + 1), quoted: true };
}
return null;
}
const match = input.match(/^([A-Za-z_][A-Za-z0-9_$#@]*)(.*)$/s);
if (!match) return null;
return { value: match[1], rest: match[2], quoted: false };
}
function readIdentifierAfterKeyword(line: string, keyword: RegExp): string | null {
const match = line.trimStart().match(keyword);
if (!match) return null;
return readSqlIdentifier(line.trimStart().slice(match[0].length))?.value ?? null;
}
function ddlLineIdentity(line: string): string | null {
const trimmed = line.trimStart();
const constraint = readIdentifierAfterKeyword(trimmed, /^CONSTRAINT\s+/i);
if (constraint !== null) return `constraint:${constraint}`;
const index = readIdentifierAfterKeyword(trimmed, /^(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?(?:KEY|INDEX)\s+/i);
if (index !== null) return `index:${index}`;
const createdIndex = readIdentifierAfterKeyword(trimmed, /^CREATE\s+(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?INDEX\s+/i);
if (createdIndex !== null) return `index:${createdIndex}`;
if (/^PRIMARY\s+KEY\b/i.test(trimmed)) return "constraint:primary-key";
const identifier = readSqlIdentifier(trimmed);
if (!identifier || !/^\s+/.test(identifier.rest)) return null;
const normalizedIdentifier = identifier.quoted ? identifier.value : identifier.value.toLowerCase();
if (!identifier.quoted && SQL_LINE_KEYWORDS.has(normalizedIdentifier)) return `keyword:${normalizedIdentifier}`;
return `column:${normalizedIdentifier}`;
}
function isNamedDdlIdentity(identity: string | null): identity is string {
return identity !== null && (identity.startsWith("column:") || identity.startsWith("constraint:") || identity.startsWith("index:"));
}
function comparableDdlLine(line: string): string {
return line.trim().replace(/,\s*$/, "");
}
function linePairSimilarity(left: string, right: string, leftIdentity = ddlLineIdentity(left), rightIdentity = ddlLineIdentity(right)): number | null {
if (leftIdentity !== null || rightIdentity !== null) {
if (leftIdentity === null || rightIdentity === null || leftIdentity !== rightIdentity) return null;
}
const similarity = computeSimilarity(left, right);
return similarity >= SIMILARITY_THRESHOLD ? similarity : null;
}
function alignWithinWindow(removedLines: string[], addedLines: string[]): AlignedItem[] {
const result: AlignedItem[] = [];
let r = 0;
let a = 0;
while (r < removedLines.length && a < addedLines.length) {
let bestR = -1;
let bestA = -1;
let bestSimilarity = -1;
for (let ri = r; ri < Math.min(r + ALIGN_WINDOW, removedLines.length); ri++) {
for (let ai = a; ai < Math.min(a + ALIGN_WINDOW, addedLines.length); ai++) {
const similarity = linePairSimilarity(removedLines[ri], addedLines[ai]);
if (similarity !== null && similarity > bestSimilarity) {
bestSimilarity = similarity;
bestR = ri;
bestA = ai;
}
}
}
if (bestR < 0 || bestA < 0) {
result.push({ type: "delete", left: removedLines[r++] });
result.push({ type: "insert", right: addedLines[a++] });
continue;
}
while (r < bestR) result.push({ type: "delete", left: removedLines[r++] });
while (a < bestA) result.push({ type: "insert", right: addedLines[a++] });
result.push({ type: "modify", left: removedLines[r++], right: addedLines[a++] });
}
while (r < removedLines.length) result.push({ type: "delete", left: removedLines[r++] });
while (a < addedLines.length) result.push({ type: "insert", right: addedLines[a++] });
return result;
}
function buildGlobalAlignmentDirections(removedLines: string[], addedLines: string[], removedIdentities: Array<string | null>, addedIdentities: Array<string | null>): Uint8Array {
const removedCount = removedLines.length;
const addedCount = addedLines.length;
const directions = new Uint8Array(removedCount * addedCount);
let nextCosts = new Float64Array(addedCount + 1);
for (let a = addedCount - 1; a >= 0; a--) nextCosts[a] = nextCosts[a + 1] + 1;
for (let r = removedCount - 1; r >= 0; r--) {
const currentCosts = new Float64Array(addedCount + 1);
currentCosts[addedCount] = nextCosts[addedCount] + 1;
for (let a = addedCount - 1; a >= 0; a--) {
let bestCost = nextCosts[a] + 1;
let direction = ALIGN_DELETE;
const insertCost = currentCosts[a + 1] + 1;
if (insertCost < bestCost) {
bestCost = insertCost;
direction = ALIGN_INSERT;
}
const similarity = linePairSimilarity(removedLines[r], addedLines[a], removedIdentities[r], addedIdentities[a]);
if (similarity !== null) {
const modifyCost = nextCosts[a + 1] + (1 - similarity);
if (modifyCost <= bestCost) {
bestCost = modifyCost;
direction = ALIGN_MODIFY;
}
}
currentCosts[a] = bestCost;
directions[r * addedCount + a] = direction;
}
nextCosts = currentCosts;
}
return directions;
}
function alignLineByLine(removedLines: string[], addedLines: string[]): AlignedItem[] {
const removedCount = removedLines.length;
const addedCount = addedLines.length;
if (removedCount * addedCount > MAX_GLOBAL_ALIGNMENT_CELLS) return alignWithinWindow(removedLines, addedLines);
const removedIdentities = removedLines.map(ddlLineIdentity);
const addedIdentities = addedLines.map(ddlLineIdentity);
const directions = buildGlobalAlignmentDirections(removedLines, addedLines, removedIdentities, addedIdentities);
const result: AlignedItem[] = [];
let r = 0;
let a = 0;
while (r < removedLines.length || a < addedLines.length) {
if (r < removedLines.length && a < addedLines.length) {
const directSim = computeSimilarity(removedLines[r], addedLines[a]);
if (directSim >= SIMILARITY_THRESHOLD) {
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
r++;
a++;
continue;
}
// Look ahead for best match within a small window
let bestR = -1;
let bestA = -1;
let bestSim = SIMILARITY_THRESHOLD;
for (let ri = r; ri < Math.min(r + ALIGN_WINDOW, removedLines.length); ri++) {
for (let aj = a; aj < Math.min(a + ALIGN_WINDOW, addedLines.length); aj++) {
const sim = computeSimilarity(removedLines[ri], addedLines[aj]);
if (sim > bestSim) {
bestSim = sim;
bestR = ri;
bestA = aj;
}
}
}
if (bestR >= 0 && bestA >= 0) {
while (r < bestR) {
result.push({ type: "delete", left: removedLines[r] });
r++;
}
while (a < bestA) {
result.push({ type: "insert", right: addedLines[a] });
a++;
}
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
r++;
a++;
} else {
result.push({ type: "delete", left: removedLines[r] });
result.push({ type: "insert", right: addedLines[a] });
r++;
a++;
}
} else if (r < removedLines.length) {
if (r >= removedLines.length) {
result.push({ type: "insert", right: addedLines[a] });
a++;
} else if (a >= addedLines.length) {
result.push({ type: "delete", left: removedLines[r] });
r++;
} else {
result.push({ type: "insert", right: addedLines[a] });
a++;
const direction = directions[r * addedCount + a];
if (direction === ALIGN_MODIFY) {
result.push({ type: "modify", left: removedLines[r], right: addedLines[a] });
r++;
a++;
} else if (direction === ALIGN_INSERT) {
result.push({ type: "insert", right: addedLines[a] });
a++;
} else {
result.push({ type: "delete", left: removedLines[r] });
r++;
}
}
}
return result;
}
type DiffLineReference = {
line: DiffLine;
identity: string;
};
function collectChangedLineReferences(hunks: DiffHunk[], side: "leftLines" | "rightLines", hunkType: "delete" | "insert"): DiffLineReference[] {
const references: DiffLineReference[] = [];
for (const hunk of hunks) {
if (hunk.type !== hunkType) continue;
for (const line of hunk[side]) {
if (line.isPadding || line.type !== hunkType) continue;
const identity = ddlLineIdentity(line.content);
if (isNamedDdlIdentity(identity)) references.push({ line, identity });
}
}
return references;
}
function reconcileMovedDdlLines(hunks: DiffHunk[]): void {
const deletedLines = collectChangedLineReferences(hunks, "leftLines", "delete");
const insertedByIdentity = new Map<string, DiffLineReference[]>();
for (const reference of collectChangedLineReferences(hunks, "rightLines", "insert")) {
const matches = insertedByIdentity.get(reference.identity) ?? [];
matches.push(reference);
insertedByIdentity.set(reference.identity, matches);
}
for (const deleted of deletedLines) {
const matches = insertedByIdentity.get(deleted.identity);
if (!matches?.length) continue;
const exactIndex = matches.findIndex((inserted) => comparableDdlLine(inserted.line.content) === comparableDdlLine(deleted.line.content));
const inserted = matches.splice(exactIndex >= 0 ? exactIndex : 0, 1)[0];
if (comparableDdlLine(deleted.line.content) === comparableDdlLine(inserted.line.content)) {
deleted.line.type = "equal";
inserted.line.type = "equal";
continue;
}
deleted.line.type = "modify";
inserted.line.type = "modify";
deleted.line.comparisonContent = inserted.line.content;
inserted.line.comparisonContent = deleted.line.content;
}
}
export function buildHunks(sourceDdl: string, targetDdl: string): DiffHunk[] {
const normalizedSource = normalizeDdl(sourceDdl);
const normalizedTarget = normalizeDdl(targetDdl);
@@ -282,6 +519,7 @@ export function buildHunks(sourceDdl: string, targetDdl: string): DiffHunk[] {
}
}
reconcileMovedDdlLines(hunks);
return hunks;
}
@@ -88,35 +88,38 @@ const { syncScroll: rollbackSyncScroll, measureHunks: rollbackMeasureHunks } = u
hunks: rollbackHunks,
});
// Cache char-level diff segments so we don't recompute on every render
const modifySegments = computed(() => {
function collectModifySegments(diffHunks: ReturnType<typeof buildHunks>) {
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
for (const hunk of hunks.value) {
if (hunk.type !== "modify") continue;
for (const hunk of diffHunks) {
for (let i = 0; i < hunk.leftLines.length; i++) {
const left = hunk.leftLines[i];
const right = hunk.rightLines[i];
if (left.isPadding || right.isPadding) continue;
const key = `${hunk.id}:${i}`;
map.set(key, renderModifyLine(left, right));
if (left.type === "modify" && !left.isPadding && right.type === "modify" && !right.isPadding) {
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,
rightSegments: [],
});
} else if (right.type === "modify" && !right.isPadding && right.comparisonContent !== undefined) {
map.set(key, {
leftSegments: [],
rightSegments: renderModifyContent(right.comparisonContent, right.content).targetSegments,
});
}
}
}
return map;
}
// Cache char-level diff segments so we don't recompute on every render
const modifySegments = computed(() => {
return collectModifySegments(hunks.value);
});
const rollbackModifySegments = computed(() => {
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
for (const hunk of rollbackHunks.value) {
if (hunk.type !== "modify") continue;
for (let i = 0; i < hunk.leftLines.length; i++) {
const left = hunk.leftLines[i];
const right = hunk.rightLines[i];
if (left.isPadding || right.isPadding) continue;
const key = `${hunk.id}:${i}`;
map.set(key, renderModifyLine(left, right));
}
}
return map;
return collectModifySegments(rollbackHunks.value);
});
let measureRaf: number | null = null;
@@ -277,7 +280,12 @@ function computeCharDiffs(source: string, target: string): { source: string; tar
}
function renderModifyLine(leftLine: DiffLine, rightLine: DiffLine): { leftSegments: Segment[]; rightSegments: Segment[] } {
const charDiffs = computeCharDiffs(leftLine.content, rightLine.content);
const { sourceSegments, targetSegments } = renderModifyContent(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) {
@@ -289,7 +297,7 @@ function renderModifyLine(leftLine: DiffLine, rightLine: DiffLine): { leftSegmen
if (cd.target) rightSegments.push({ text: cd.target, changed: true });
}
}
return { leftSegments, rightSegments };
return { sourceSegments: leftSegments, targetSegments: rightSegments };
}
interface Segment {
@@ -387,9 +395,7 @@ function copyDeploySqlAll() {
:key="`l-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.leftLines.length - 1,
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
@@ -421,9 +427,7 @@ function copyDeploySqlAll() {
:key="`r-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.rightLines.length - 1,
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
@@ -467,9 +471,7 @@ function copyDeploySqlAll() {
:key="`l-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.leftLines.length - 1,
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
@@ -499,9 +501,7 @@ function copyDeploySqlAll() {
:key="`r-${hunk.id}-${idx}`"
class="flex min-h-[1.5em]"
:class="{
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.rightLines.length - 1,
'border rounded-sm border-yellow-500/40': line.type === 'modify',
}"
:style="{ backgroundColor: lineBackground(line) }"
>
@@ -9,10 +9,10 @@ import { useTheme } from "@/composables/useTheme";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { Splitpanes, Pane } from "splitpanes";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind, CompatibilityWarning, RenameCandidate, MissingRollbackObject, RollbackCompleteness } from "@/lib/schema/schemaDiff";
import { schemaDiffReviewAlert, 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, ListTree, Link2, Zap, AlertTriangle, ShieldCheck } from "@lucide/vue";
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, AlertTriangle, ShieldCheck } from "@lucide/vue";
const { t } = useI18n();
const { toast } = useToast();
@@ -34,6 +34,7 @@ const props = defineProps<{
rollbackCompleteness?: RollbackCompleteness;
missingRollbackObjects?: MissingRollbackObject[];
canExecute?: boolean;
destructiveStatementCount?: number;
}>();
const emit = defineEmits<{
@@ -96,19 +97,13 @@ const topLevelObjects = computed(() => {
});
const operationCounts = computed(() => {
const counts: Record<DiffOperationType, number> = { create: 0, modify: 0, delete: 0, none: 0 };
for (const obj of topLevelObjects.value) {
counts[obj.operationType]++;
}
return counts;
return summarizeSchemaDiffOperations(props.selectedObjects);
});
const riskLevel = computed(() => {
const count = props.compatibilityWarnings?.length ?? 0;
if (count === 0) return "safe";
if (count <= 3) return "caution";
return "dangerous";
});
const selectedObjectCount = computed(() => selectedSchemaDiffObjects(props.selectedObjects).length);
const compatibilityWarningCount = computed(() => props.compatibilityWarnings?.length ?? 0);
const reviewAlert = computed(() => schemaDiffReviewAlert(props.destructiveStatementCount ?? 0, compatibilityWarningCount.value));
const renameCount = computed(() => props.renameCandidates?.length ?? 0);
@@ -252,6 +247,8 @@ function getObjectIcon(kind: DiffObjectKind) {
return ScrollText;
case "owner":
return UserCog;
case "column":
return Columns3;
case "index":
return ListTree;
case "foreignKey":
@@ -277,6 +274,8 @@ function getObjectIconColor(kind: DiffObjectKind): string {
return "text-pink-500";
case "owner":
return "text-indigo-500";
case "column":
return "text-sky-500";
case "index":
return "text-teal-500";
case "foreignKey":
@@ -299,11 +298,10 @@ function getObjectIconColor(kind: DiffObjectKind): string {
{{ t("diff.backToResult") }}
</Button>
<span class="text-sm font-medium">{{ t("diff.deployReview") }}</span>
<span class="text-xs text-muted-foreground"> ({{ t("diff.selectedCount", { selected: topLevelObjects.length, total: topLevelObjects.length }) }}) </span>
<!-- Risk level badge -->
<span v-if="riskLevel !== 'safe'" class="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium" :class="riskLevel === 'dangerous' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'">
<span class="text-xs text-muted-foreground"> ({{ t("diff.selectedCount", { selected: selectedObjectCount, total: selectedObjectCount }) }}) </span>
<span v-if="reviewAlert" class="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium" :class="reviewAlert === 'destructive' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'">
<AlertTriangle class="w-3 h-3" />
{{ riskLevel === "dangerous" ? t("diff.riskLevel.dangerous", { count: compatibilityWarnings?.length ?? 0 }) : t("diff.riskLevel.caution", { count: compatibilityWarnings?.length ?? 0 }) }}
{{ reviewAlert === "destructive" ? t("diff.destructiveSqlDetected", { count: destructiveStatementCount }) : t("diff.compatibilityWarningsDetected", { count: compatibilityWarningCount }) }}
</span>
</div>
<div class="flex items-center gap-3 text-xs">
@@ -25,9 +25,14 @@ import type { ObjectSourceKind, TableInfo } from "@/types/database";
import {
buildDeploySqlForObjects,
convertToSchemaDiffObjects,
detectDestructiveSchemaDiffStatements,
groupDiffObjects,
injectColumnRenameSql,
schemaDiffDeployTargetSchema,
schemaDiffSelectionOwnerId,
selectedSchemaDiffObjects,
setSchemaDiffObjectSelected,
summarizeSchemaDiffOperations,
databaseTypeToDialectKind,
normalizeDialectKind,
type OperationGroup,
@@ -536,59 +541,43 @@ function handleToggleTypeGroup(operationType: DiffOperationType, kind: DiffObjec
}
function handleToggleGroupSelection(operationType: DiffOperationType, selected: boolean) {
diffGroups.value = diffGroups.value.map((g) => {
if (g.operationType !== operationType) return g;
return {
...g,
selectedCount: selected ? g.count : 0,
typeGroups: g.typeGroups.map((tg) => {
for (const obj of tg.objects) {
obj.selected = selected;
}
return { ...tg, selectedCount: selected ? tg.objects.length : 0 };
}),
};
});
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);
}
rebuildDiffGroups();
regenerateDeploySql();
}
function handleToggleTypeSelection(operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean) {
diffGroups.value = diffGroups.value.map((g) => {
if (g.operationType !== operationType) return g;
const newTypeGroups = g.typeGroups.map((tg) => {
if (tg.kind !== kind) return tg;
for (const obj of tg.objects) {
obj.selected = selected;
}
return { ...tg, selectedCount: selected ? tg.objects.length : 0 };
});
const newSelectedCount = newTypeGroups.reduce((sum, tg) => sum + tg.selectedCount, 0);
return { ...g, selectedCount: newSelectedCount, typeGroups: newTypeGroups };
});
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);
}
rebuildDiffGroups();
regenerateDeploySql();
}
function handleToggleObjectSelection(objectId: string, selected: boolean) {
const obj = diffObjects.value.find((o) => o.id === objectId);
if (!obj) return;
obj.selected = selected;
// Update diffGroups with new references to trigger reactivity
diffGroups.value = diffGroups.value.map((g) => {
if (g.operationType !== obj.operationType) return g;
const newTypeGroups = g.typeGroups.map((tg) => {
if (tg.kind !== obj.objectKind) return tg;
const newSelectedCount = tg.objects.filter((o) => o.selected).length;
return { ...tg, selectedCount: newSelectedCount };
});
const newSelectedCount = newTypeGroups.reduce((sum, tg) => sum + tg.selectedCount, 0);
return { ...g, selectedCount: newSelectedCount, typeGroups: newTypeGroups };
});
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 rebuildDiffGroups() {
const expanded = new Map(diffGroups.value.map((group) => [group.operationType, group.expanded]));
const typeExpanded = new Map(diffGroups.value.flatMap((group) => group.typeGroups.map((typeGroup) => [`${group.operationType}:${typeGroup.kind}`, typeGroup.expanded] as const)));
diffGroups.value = groupDiffObjects(diffObjects.value).map((group) => ({
...group,
expanded: expanded.get(group.operationType) ?? group.expanded,
typeGroups: group.typeGroups.map((typeGroup) => ({
...typeGroup,
expanded: typeExpanded.get(`${group.operationType}:${typeGroup.kind}`) ?? typeGroup.expanded,
})),
}));
}
function regenerateDeploySql() {
deploySql.value = buildDeploySqlForObjects(diffObjects.value);
}
@@ -613,6 +602,11 @@ const canExecuteDeploy = computed(() => {
return true;
});
const destructiveStatements = computed(() => {
const databaseType = store.getConfig(targetConnectionId.value)?.db_type;
return detectDestructiveSchemaDiffStatements(deploySql.value, databaseType);
});
function applyRename(rc: RenameCandidate) {
let found = false;
for (const obj of diffObjects.value) {
@@ -665,7 +659,7 @@ async function handleExecuteScript() {
return;
}
await executeDeploySql();
await handleDeploy();
}
async function executeDeploySql() {
@@ -678,7 +672,7 @@ async function executeDeploySql() {
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: async () => {
const txLog = await api.executeScriptWith2pc(targetConnectionId.value, targetDatabase.value, [deploySql.value], targetSchema.value);
const txLog = await api.executeScriptWith2pc(targetConnectionId.value, targetDatabase.value, [deploySql.value], targetSchema.value, destructiveStatements.value.length > 0);
return txLog;
},
});
@@ -827,14 +821,12 @@ async function onConfirmDeploy() {
}
const deployStats = computed(() => {
const selected = diffObjects.value.filter((o) => o.selected && o.operationType !== "none");
const isTopLevel = (o: SchemaDiffObject) => !o.id.startsWith("col-") && !o.id.startsWith("idx-") && !o.id.startsWith("fk-") && !o.id.startsWith("trg-");
const topLevel = selected.filter(isTopLevel);
const counts = summarizeSchemaDiffOperations(diffObjects.value);
return {
create: topLevel.filter((o) => o.operationType === "create").length,
modify: topLevel.filter((o) => o.operationType === "modify").length,
delete: topLevel.filter((o) => o.operationType === "delete").length,
total: topLevel.length,
create: counts.create,
modify: counts.modify,
delete: counts.delete,
total: selectedSchemaDiffObjects(diffObjects.value).length,
};
});
@@ -993,6 +985,7 @@ const targetConnectionInfo = computed(() => {
:rollback-completeness="rollbackCompleteness"
:missing-rollback-objects="missingRollbackObjects"
:can-execute="canExecuteDeploy"
:destructive-statement-count="destructiveStatements.length"
@update:deploy-sql-mode="switchDeploySqlMode"
@back="step = 'result'"
@deploy="handleDeploy"
@@ -1057,6 +1050,13 @@ const targetConnectionInfo = computed(() => {
<span class="text-blue-600">{{ t("diff.modify") }}: {{ deployStats.modify }}</span>
<span class="text-red-600">{{ t("diff.delete") }}: {{ deployStats.delete }}</span>
</div>
<div v-if="destructiveStatements.length > 0" class="rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200">
<div class="font-semibold">{{ t("diff.destructiveSqlDetected", { count: destructiveStatements.length }) }}</div>
<ul class="mt-2 max-h-32 space-y-1 overflow-auto font-mono">
<li v-for="(item, index) in destructiveStatements" :key="`${item.objectType}-${index}`" class="truncate" :title="item.statement">{{ item.action.toUpperCase() }} {{ item.objectType }}: {{ item.statement }}</li>
</ul>
</div>
</div>
<DialogFooter>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schema/schemaDiff";
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, Columns3, ListTree, Link2, Zap, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
const { t } = useI18n();
@@ -70,6 +70,8 @@ function getObjectIcon(kind: DiffObjectKind) {
return ScrollText;
case "owner":
return UserCog;
case "column":
return Columns3;
case "index":
return ListTree;
case "foreignKey":
@@ -95,6 +97,8 @@ function getObjectIconColor(kind: DiffObjectKind): string {
return "text-pink-500";
case "owner":
return "text-indigo-500";
case "column":
return "text-sky-500";
case "index":
return "text-teal-500";
case "foreignKey":
@@ -120,6 +124,8 @@ function getObjectTypeLabel(kind: DiffObjectKind): string {
return "diff.objectKindLabel.rule";
case "owner":
return "diff.objectKindLabel.owner";
case "column":
return "diff.objectKindLabel.column";
case "index":
return "diff.objectKindLabel.index";
case "foreignKey":
@@ -164,10 +170,11 @@ function onObjectCheckboxChange(obj: SchemaDiffObject, event: Event) {
}
function formatObjectName(obj: SchemaDiffObject): string {
const name = obj.parentName ? `${obj.parentName}.${obj.name}` : obj.name;
if (obj.objectKind === "function" && obj.arguments) {
return `${obj.name}(${obj.arguments})`;
return `${name}(${obj.arguments})`;
}
return obj.name;
return name;
}
</script>
@@ -217,7 +224,7 @@ function formatObjectName(obj: SchemaDiffObject): string {
<input type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'create' ? 'text-green-500' : ''">
{{ obj.sourceName ? (obj.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
{{ obj.sourceName ? (obj.parentName ? `${obj.parentName}.${obj.sourceName}` : obj.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
</span>
</div>
<div v-else></div>
@@ -232,7 +239,7 @@ function formatObjectName(obj: SchemaDiffObject): string {
<input v-if="obj.operationType === 'delete'" type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'delete' ? 'text-red-500 line-through' : ''">
{{ obj.targetName ? (obj.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
{{ obj.targetName ? (obj.parentName ? `${obj.parentName}.${obj.targetName}` : obj.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
</span>
</div>
<div v-else></div>