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>
+4
View File
@@ -5166,6 +5166,9 @@ export default {
deployToServer: "Deploy to Server",
deployConfirmTitle: "Final Confirmation",
deployConfirmMessage: "You are about to deploy the following changes to the target database",
destructiveSqlDetected: "{count} destructive operation(s) detected; explicit confirmation is required",
compatibilityWarnings: "Compatibility Warnings",
compatibilityWarningsDetected: "Compatibility warnings: {count}",
targetServer: "Target Server",
dbVersion: "Database Version",
targetDatabase: "Target Database",
@@ -5194,6 +5197,7 @@ export default {
sequence: "Sequence",
rule: "Rule",
owner: "Owner",
column: "Column",
index: "Index",
foreignKey: "Foreign Key",
trigger: "Trigger",
+4
View File
@@ -4922,6 +4922,9 @@ export default withEnglishFallback({
deployToServer: "Desplegar en servidor",
deployConfirmTitle: "Confirmación final",
deployConfirmMessage: "Estás a punto de desplegar los siguientes cambios en la base de datos de destino",
destructiveSqlDetected: "Se detectaron {count} operaciones destructivas; se requiere confirmación explícita",
compatibilityWarnings: "Advertencias de compatibilidad",
compatibilityWarningsDetected: "Advertencias de compatibilidad: {count}",
targetServer: "Servidor de destino",
dbVersion: "Versión de base de datos",
targetDatabase: "Base de datos de destino",
@@ -4950,6 +4953,7 @@ export default withEnglishFallback({
sequence: "Secuencia",
rule: "Regla",
owner: "Propietario",
column: "Columna",
index: "Índice",
foreignKey: "Clave foránea",
trigger: "Disparador",
+4
View File
@@ -4922,6 +4922,9 @@ export default withEnglishFallback({
deployToServer: "Distribuisci sul Server",
deployConfirmTitle: "Conferma Finale",
deployConfirmMessage: "Stai per distribuire le seguenti modifiche al database di destinazione",
destructiveSqlDetected: "Rilevate {count} operazioni distruttive; è richiesta una conferma esplicita",
compatibilityWarnings: "Avvisi di compatibilità",
compatibilityWarningsDetected: "Avvisi di compatibilità: {count}",
targetServer: "Server Destinazione",
dbVersion: "Versione Database",
targetDatabase: "Database Destinazione",
@@ -4950,6 +4953,7 @@ export default withEnglishFallback({
sequence: "Sequenza",
rule: "Regola",
owner: "Proprietario",
column: "Colonna",
index: "Indice",
foreignKey: "Chiave Esterna",
trigger: "Trigger",
+4
View File
@@ -4942,6 +4942,9 @@ export default withEnglishFallback({
deployToServer: "サーバーにデプロイ",
deployConfirmTitle: "最終確認",
deployConfirmMessage: "以下の変更をターゲットデータベースにデプロイしようとしています",
destructiveSqlDetected: "{count} 件の破壊的操作を検出しました。実行には明示的な確認が必要です",
compatibilityWarnings: "互換性に関する警告",
compatibilityWarningsDetected: "互換性に関する警告: {count} 件",
targetServer: "ターゲットサーバー",
dbVersion: "データベースバージョン",
targetDatabase: "ターゲットデータベース",
@@ -4966,6 +4969,7 @@ export default withEnglishFallback({
sequence: "シーケンス",
rule: "ルール",
owner: "所有者",
column: "カラム",
index: "インデックス",
foreignKey: "外部キー",
trigger: "トリガー",
+4
View File
@@ -4665,6 +4665,9 @@ export default withEnglishFallback({
deployToServer: "서버에 배포",
deployConfirmTitle: "최종 확인",
deployConfirmMessage: "다음 변경 사항을 대상 데이터베이스에 배포하려고 합니다",
destructiveSqlDetected: "파괴적인 작업 {count}개가 감지되었습니다. 명시적인 확인이 필요합니다",
compatibilityWarnings: "호환성 경고",
compatibilityWarningsDetected: "호환성 경고: {count}개",
targetServer: "대상 서버",
dbVersion: "데이터베이스 버전",
targetDatabase: "대상 데이터베이스",
@@ -4693,6 +4696,7 @@ export default withEnglishFallback({
sequence: "시퀀스",
rule: "룰",
owner: "소유자",
column: "열",
index: "인덱스",
foreignKey: "외래 키",
trigger: "트리거",
+4
View File
@@ -4924,6 +4924,9 @@ export default withEnglishFallback({
deployToServer: "Implantar no Servidor",
deployConfirmTitle: "Confirmação Final",
deployConfirmMessage: "Você está prestes a implantar as seguintes alterações no banco de dados de destino",
destructiveSqlDetected: "Foram detectadas {count} operações destrutivas; é necessária uma confirmação explícita",
compatibilityWarnings: "Avisos de compatibilidade",
compatibilityWarningsDetected: "Avisos de compatibilidade: {count}",
targetServer: "Servidor de Destino",
dbVersion: "Versão do Banco de Dados",
targetDatabase: "Banco de Dados de Destino",
@@ -4952,6 +4955,7 @@ export default withEnglishFallback({
sequence: "Sequência",
rule: "Regra",
owner: "Proprietário",
column: "Coluna",
index: "Índice",
foreignKey: "Chave Estrangeira",
trigger: "Gatilho",
+4
View File
@@ -5155,6 +5155,9 @@ export default withEnglishFallback({
deployToServer: "部署到服务器",
deployConfirmTitle: "最终确认",
deployConfirmMessage: "您即将在目标数据库执行以下部署操作",
destructiveSqlDetected: "检测到 {count} 个破坏性操作,确认后才允许执行",
compatibilityWarnings: "兼容性警告",
compatibilityWarningsDetected: "兼容性警告:{count}",
targetServer: "目标服务器",
dbVersion: "数据库版本",
targetDatabase: "目标数据库",
@@ -5179,6 +5182,7 @@ export default withEnglishFallback({
sequence: "序列",
rule: "规则",
owner: "所有者",
column: "字段",
index: "索引",
foreignKey: "外键",
trigger: "触发器",
+4
View File
@@ -4240,6 +4240,7 @@ export default withEnglishFallback({
sequence: "序列",
rule: "規則",
owner: "擁有者",
column: "欄位",
index: "索引",
foreignKey: "外鍵",
trigger: "觸發器",
@@ -4274,6 +4275,9 @@ export default withEnglishFallback({
deployToServer: "部署到伺服器",
deployConfirmTitle: "最終確認",
deployConfirmMessage: "您即將對目標資料庫部署以下變更",
destructiveSqlDetected: "偵測到 {count} 個破壞性操作,確認後才允許執行",
compatibilityWarnings: "相容性警告",
compatibilityWarningsDetected: "相容性警告:{count}",
targetServer: "目標伺服器",
dbVersion: "資料庫版本",
confirmDeploy: "確認部署",
+15
View File
@@ -25,3 +25,18 @@ Regression coverage belongs in:
- `src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts`
- `src/lib/__tests__/schema/schemaDiffMetadataLoad.spec.ts`
## Schema Diff Deployment Safety
Schema Diff review and execution must preserve these invariants:
- Review groups preserve the original top-level object hierarchy. A modified table with dropped or rebuilt indexes, columns, foreign keys, or triggers appears once in the delete group at table level, while final SQL review lists the exact destructive operations.
- A table and its nested differences are one selection unit because the backend returns one aggregated `syncSql` block per table. Clearing a nested object must clear the table so hidden sibling DDL cannot execute.
- Final deploy SQL is scanned again after user edits. Any `DROP`, `TRUNCATE`, or `ALTER ... DROP` operation must be listed in the final confirmation.
- The core deploy endpoint rejects destructive Schema Diff SQL unless the request carries explicit destructive confirmation. UI classification is never the only safety boundary.
Regression coverage belongs in:
- `packages/app-tests/schemaDiff.test.ts`
- `crates/dbx-core/src/query.rs`
- `src-tauri/src/commands/query.rs`
+2 -1
View File
@@ -1038,12 +1038,13 @@ export async function executeScript(connectionId: string, database: string, sql:
});
}
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise<any> {
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string, destructiveConfirmed = false): Promise<any> {
return post("/api/query/execute-script-2pc", {
connectionId,
database,
statements,
schema,
destructiveConfirmed,
});
}
+2 -1
View File
@@ -1258,12 +1258,13 @@ export async function executeScript(connectionId: string, database: string, sql:
return invoke("execute_script", { connectionId, database, sql, schema });
}
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise<TransactionLog> {
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string, destructiveConfirmed = false): Promise<TransactionLog> {
return invoke("execute_script_with_2pc", {
connectionId,
database,
statements,
schema,
destructiveConfirmed,
});
}
+239 -8
View File
@@ -1,4 +1,5 @@
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
const DIALECT_KIND_MAP: Record<string, string> = {
mysql: "mysql",
@@ -304,7 +305,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" | "index" | "trigger" | "foreignKey";
export type DiffObjectKind = "table" | "view" | "function" | "sequence" | "rule" | "owner" | "column" | "index" | "trigger" | "foreignKey";
export interface SchemaDiffObject {
id: string;
@@ -320,6 +321,8 @@ export interface SchemaDiffObject {
rollbackDdl?: string;
changes?: string[];
children?: SchemaDiffObject[];
parentId?: string;
parentName?: string;
/** Function arguments signature (for PostgreSQL overloaded functions) */
arguments?: string;
renameMetadata?: {
@@ -339,6 +342,148 @@ export interface SchemaDiffGroup {
objects: SchemaDiffObject[];
}
export interface DestructiveSchemaDiffStatement {
action: "drop" | "truncate";
objectType: string;
statement: string;
}
function stripSqlCommentsAndStringLiterals(sql: string): string {
let result = "";
let index = 0;
let state: "plain" | "single" | "double" | "backtick" | "bracket" | "line" | "block" = "plain";
while (index < sql.length) {
const current = sql[index];
const next = sql[index + 1];
if (state === "line") {
if (current === "\n") {
state = "plain";
result += "\n";
} else {
result += " ";
}
index++;
continue;
}
if (state === "block") {
if (current === "*" && next === "/") {
result += " ";
state = "plain";
index += 2;
} else {
result += current === "\n" ? "\n" : " ";
index++;
}
continue;
}
if (state === "single" || state === "double" || state === "backtick") {
result += current === "\n" ? "\n" : " ";
const delimiter = state === "single" ? "'" : state === "double" ? '"' : "`";
if (current === delimiter && next === delimiter) {
result += " ";
index += 2;
} else {
if (current === delimiter) state = "plain";
index++;
}
continue;
}
if (state === "bracket") {
result += current === "\n" ? "\n" : " ";
if (current === "]" && next === "]") {
result += " ";
index += 2;
} else {
if (current === "]") state = "plain";
index++;
}
continue;
}
if (current === "-" && next === "-") {
result += " ";
state = "line";
index += 2;
continue;
}
if (current === "#") {
result += " ";
state = "line";
index++;
continue;
}
if (current === "/" && next === "*") {
result += " ";
state = "block";
index += 2;
continue;
}
if (current === "'") {
result += " ";
state = "single";
index++;
continue;
}
if (current === '"') {
result += " ";
state = "double";
index++;
continue;
}
if (current === "`") {
result += " ";
state = "backtick";
index++;
continue;
}
if (current === "[") {
result += " ";
state = "bracket";
index++;
continue;
}
result += current;
index++;
}
return result;
}
export function detectDestructiveSchemaDiffStatements(sql: string, databaseType?: DatabaseType): DestructiveSchemaDiffStatement[] {
const statements = splitSqlStatementRanges(sql, databaseType);
const destructive: DestructiveSchemaDiffStatement[] = [];
for (const range of statements) {
const statement = range.sql.trim().replace(/;\s*$/, "");
const normalized = stripSqlCommentsAndStringLiterals(statement).trim();
const topLevel = normalized.match(/^DROP\s+(?:TEMPORARY\s+)?(MATERIALIZED\s+VIEW|FOREIGN\s+TABLE|EVENT\s+TRIGGER|USER\s+MAPPING|OWNED\s+BY|[A-Z_]+)\b/i);
if (topLevel) {
destructive.push({ action: "drop", objectType: topLevel[1].toUpperCase(), statement });
continue;
}
if (/^TRUNCATE\b/i.test(normalized)) {
destructive.push({ action: "truncate", objectType: "TABLE", statement });
continue;
}
if (/^ALTER\b/i.test(normalized)) {
const dropClauses = normalized.matchAll(/\bDROP\s+(COLUMN|CONSTRAINT|INDEX|KEY|PRIMARY\s+KEY|FOREIGN\s+KEY|PARTITION|DEFAULT)\b/gi);
for (const match of dropClauses) {
destructive.push({ action: "drop", objectType: match[1].toUpperCase(), statement });
}
}
}
return destructive;
}
export function getOperationType(diffType: string): DiffOperationType {
switch (diffType) {
case "modified":
@@ -407,32 +552,41 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
...(diff.columns?.map((c) => ({
id: `col-${diff.name}-${c.name}`,
operationType: getOperationType(c.type),
objectKind: "table" as DiffObjectKind,
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}`,
operationType: getOperationType(i.type),
// 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}`,
operationType: getOperationType(f.type),
// 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}`,
@@ -443,6 +597,8 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
targetName: t.type === "removed" ? undefined : t.name,
selected: opType !== "none",
changes: t.changes,
parentId: `table-${diff.name}`,
parentName: diff.name,
})) || []),
],
};
@@ -530,8 +686,7 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
export function buildDeploySqlForObjects(objects: SchemaDiffObject[]): string {
const selected = objects.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;
return o.selected && o.operationType !== "none" && !o.parentId;
});
if (selected.length === 0) {
@@ -727,6 +882,55 @@ export interface OperationGroup {
typeGroups: ObjectTypeGroup[];
}
export function flattenSchemaDiffObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
return objects.flatMap((object) => [object, ...flattenSchemaDiffObjects(object.children ?? [])]);
}
export function findSchemaDiffObject(objects: SchemaDiffObject[], objectId: string): SchemaDiffObject | undefined {
return flattenSchemaDiffObjects(objects).find((object) => object.id === objectId);
}
export function setSchemaDiffObjectSelected(objects: SchemaDiffObject[], objectId: string, selected: boolean): boolean {
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);
return true;
}
export function selectedSchemaDiffObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
return objects.filter((object) => object.selected && object.operationType !== "none");
}
export function schemaDiffSelectionOwnerId(object: SchemaDiffObject): string {
return object.parentId ?? object.id;
}
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]++;
}
return counts;
}
export type SchemaDiffReviewAlert = "destructive" | "compatibility" | null;
export function schemaDiffReviewAlert(destructiveStatementCount: number, compatibilityWarningCount: number): SchemaDiffReviewAlert {
if (destructiveStatementCount > 0) return "destructive";
if (compatibilityWarningCount > 0) return "compatibility";
return null;
}
export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[] {
const groups: Record<DiffOperationType, Record<DiffObjectKind, SchemaDiffObject[]>> = {
modify: {
@@ -736,6 +940,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
sequence: [],
rule: [],
owner: [],
column: [],
index: [],
foreignKey: [],
trigger: [],
@@ -747,6 +952,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
sequence: [],
rule: [],
owner: [],
column: [],
index: [],
foreignKey: [],
trigger: [],
@@ -758,6 +964,7 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
sequence: [],
rule: [],
owner: [],
column: [],
index: [],
foreignKey: [],
trigger: [],
@@ -769,20 +976,21 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
sequence: [],
rule: [],
owner: [],
column: [],
index: [],
foreignKey: [],
trigger: [],
},
};
for (const obj of objects) {
for (const obj of schemaDiffReviewObjects(objects)) {
groups[obj.operationType][obj.objectKind].push(obj);
}
const order: DiffOperationType[] = ["modify", "create", "delete", "none"];
return order.map((opType) => {
const typeGroups: ObjectTypeGroup[] = [];
const kinds: DiffObjectKind[] = ["table", "view", "function", "sequence", "rule", "owner", "index", "foreignKey", "trigger"];
const kinds: DiffObjectKind[] = ["table", "view", "function", "sequence", "rule", "owner", "column", "index", "foreignKey", "trigger"];
for (const kind of kinds) {
const objs = groups[opType][kind];
@@ -809,6 +1017,27 @@ export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[]
});
}
function schemaDiffReviewObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
return objects.flatMap((object) => {
if (object.operationType !== "modify") 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];
});
}
function getObjectTypeLabel(kind: DiffObjectKind): string {
switch (kind) {
case "table":
@@ -823,6 +1052,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":