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

This commit is contained in:
zhangyc
2026-08-13 12:42:51 +08:00
committed by GitHub
parent e8156ab411
commit c51abd2178
22 changed files with 1205 additions and 175 deletions
@@ -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":
+52 -1
View File
@@ -23,7 +23,7 @@ use crate::database_capabilities;
use crate::db;
use crate::db::agent_driver::{AgentCallError, AgentErrorStage, AgentOperationOutcome};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::query_execution_sql::is_write_sql;
use crate::query_execution_sql::{is_write_sql, strip_sql_comments_and_literals};
use crate::sql::{split_sql_batches, split_sql_statements, starts_with_executable_sql_keyword_for_database};
use crate::sql_dialect::{resolve_for_db, CAP_TRANSACTIONAL_DDL};
use crate::sql_risk::{classify_sql_risk_for_database, SqlRisk};
@@ -3089,6 +3089,19 @@ fn executed_count_before_error(error: &str, statement_count: usize) -> usize {
statement_number.saturating_sub(1).min(statement_count)
}
fn is_destructive_schema_diff_statement(statement: &str) -> bool {
let normalized = strip_sql_comments_and_literals(statement).to_ascii_uppercase();
let tokens = normalized
.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
match tokens.first().copied() {
Some("DROP" | "TRUNCATE") => true,
Some("ALTER") => tokens.iter().skip(1).any(|token| *token == "DROP"),
_ => false,
}
}
/// Pure failure mapping used by deploy and unit tests.
fn schema_diff_failure_outcome(
atomicity: SchemaDiffAtomicity,
@@ -3147,6 +3160,7 @@ pub async fn execute_schema_diff_deploy(
database: &str,
statements: &[String],
schema: Option<&str>,
destructive_confirmed: bool,
) -> SchemaDiffDeployResult {
let tx_id = format!("deploy_{}", uuid::Uuid::new_v4());
let now = chrono::Utc::now().to_rfc3339();
@@ -3173,6 +3187,27 @@ pub async fn execute_schema_diff_deploy(
role: "database".to_string(),
};
let destructive_statement_count =
parsed.iter().filter(|statement| is_destructive_schema_diff_statement(statement)).count();
if destructive_statement_count > 0 && !destructive_confirmed {
return SchemaDiffDeployResult {
transaction_id: tx_id,
status: crate::two_phase_commit::TransactionStatus::RolledBack.as_str().to_string(),
participants: vec![participant],
created_at: now.clone(),
updated_at: now,
executed_count: 0,
statement_count: parsed.len(),
error: Some("Destructive schema diff SQL requires explicit confirmation".to_string()),
metadata: serde_json::json!({
"source": "schema_diff_deploy",
"mode": "single_connection_tx",
"blocked": "destructive_confirmation_required",
"destructive_statement_count": destructive_statement_count,
}),
};
}
if parsed.is_empty() {
return SchemaDiffDeployResult {
transaction_id: tx_id,
@@ -4475,6 +4510,22 @@ mod tests {
assert!(!postgres_prefers_text_protocol(Some(DatabaseType::Postgres)));
assert!(!postgres_prefers_text_protocol(None));
}
#[test]
fn schema_diff_destructive_detection_covers_drop_and_alter_drop() {
assert!(is_destructive_schema_diff_statement("DROP INDEX idx_users_email ON users"));
assert!(is_destructive_schema_diff_statement("TRUNCATE TABLE audit_log"));
assert!(is_destructive_schema_diff_statement(
"ALTER TABLE users DROP COLUMN legacy_code, DROP INDEX idx_legacy"
));
}
#[test]
fn schema_diff_destructive_detection_ignores_comments_literals_and_identifiers() {
assert!(!is_destructive_schema_diff_statement("-- DROP INDEX idx_fake\nSELECT 1"));
assert!(!is_destructive_schema_diff_statement("SELECT 'DROP TABLE users'"));
assert!(!is_destructive_schema_diff_statement("ALTER TABLE \"DROP INDEX audit\" ADD COLUMN note TEXT"));
}
#[cfg(unix)]
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType};
+27
View File
@@ -66,6 +66,7 @@ pub struct ExecuteBatchRequest {
pub schema: Option<String>,
pub catalog: Option<String>,
pub timeout_secs: Option<u64>,
pub destructive_confirmed: Option<bool>,
}
#[derive(Deserialize)]
@@ -560,6 +561,7 @@ pub async fn execute_script_with_2pc(
&req.database,
&req.statements,
req.schema.as_deref(),
req.destructive_confirmed.unwrap_or(false),
)
.await;
Ok(Json(result))
@@ -962,6 +964,7 @@ mod tests {
schema: None,
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
@@ -986,6 +989,7 @@ mod tests {
schema: None,
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req)).await.expect("empty deploy should succeed");
@@ -1007,6 +1011,7 @@ mod tests {
schema: None,
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
@@ -1020,6 +1025,28 @@ mod tests {
assert_eq!(log.executed_count, 0);
}
#[tokio::test]
async fn execute_script_with_2pc_blocks_unconfirmed_destructive_sql() {
let (state, _dir) = test_web_state().await;
let req = ExecuteBatchRequest {
connection_id: "missing-conn".to_string(),
database: "testdb".to_string(),
statements: vec!["DROP INDEX idx_old ON users".to_string()],
schema: None,
catalog: None,
timeout_secs: None,
destructive_confirmed: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
.await
.expect("destructive deploy should return a structured block result");
let log = result.0;
assert_eq!(log.status, "rolled_back");
assert_eq!(log.executed_count, 0);
assert_eq!(log.metadata["blocked"], "destructive_confirmation_required");
}
#[test]
fn execute_multi_response_preserves_nested_original_error_detail() {
let result = dbx_core::query::ExecuteMultiResult {
+168
View File
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { buildHunks } from "../../apps/desktop/src/components/diff/DiffHunkBuilder.ts";
function realLineTypes(sourceDdl: string, targetDdl: string): Map<string, string> {
const types = new Map<string, string>();
for (const hunk of buildHunks(sourceDdl, targetDdl)) {
for (const line of hunk.leftLines) {
if (!line.isPadding) types.set(`left:${line.content.trim()}`, line.type);
}
for (const line of hunk.rightLines) {
if (!line.isPadding) types.set(`right:${line.content.trim()}`, line.type);
}
}
return types;
}
test("aligns reordered DDL fields only when their identifiers match", () => {
const sourceDdl = [
"CREATE TABLE `skills` (",
" `id` bigint(20) NOT NULL AUTO_INCREMENT,",
" `allowed_tools` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,",
" `content` longtext COLLATE utf8mb4_unicode_ci,",
" `created_at` bigint(20) DEFAULT NULL,",
" `description` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL,",
" `model` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,",
" `name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,",
" `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,",
" `version` int(11) NOT NULL DEFAULT '0'",
");",
].join("\n");
const targetDdl = [
"CREATE TABLE `skills` (",
" `id` bigint(20) NOT NULL AUTO_INCREMENT,",
" `name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,",
" `description` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL,",
" `content` longtext COLLATE utf8mb4_unicode_ci,",
" `allowed_tools` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,",
" `model` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,",
" `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'draft',",
" `version` int(11) NOT NULL DEFAULT '0'",
");",
].join("\n");
const hunks = buildHunks(sourceDdl, targetDdl);
const modifiedPairs = hunks.filter((hunk) => hunk.type === "modify").map((hunk) => [hunk.leftLines[0].content.trim(), hunk.rightLines[0].content.trim()]);
assert.deepEqual(modifiedPairs, [["`status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,", "`status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'draft',"]]);
const types = realLineTypes(sourceDdl, targetDdl);
assert.equal(types.get("left:`created_at` bigint(20) DEFAULT NULL,"), "delete");
assert.equal(types.get("left:`name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,"), "equal");
assert.equal(types.get("right:`name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,"), "equal");
});
test("does not show an unchanged field as deleted when only its position changes", () => {
const sourceDdl = [
"CREATE TABLE `skills` (",
" `content` longtext,",
" `created_at` bigint(20) DEFAULT NULL,",
" `created_by` varchar(128) DEFAULT NULL,",
" `description` varchar(1024) NOT NULL,",
" `name` varchar(64) NOT NULL,",
" `skill_id` bigint(20) NOT NULL,",
" `version` int(11) NOT NULL,",
" `markdown` longtext,",
" `model` varchar(128) DEFAULT NULL",
");",
].join("\n");
const targetDdl = [
"CREATE TABLE `skills` (",
" `content` longtext,",
" `created_by` varchar(128) DEFAULT NULL,",
" `description` varchar(1024) NOT NULL,",
" `name` varchar(64) NOT NULL,",
" `skill_id` bigint(20) NOT NULL,",
" `version` int(11) NOT NULL,",
" `created_at` bigint(20) DEFAULT NULL,",
" `markdown` longtext,",
" `model` varchar(128) DEFAULT NULL",
");",
].join("\n");
const types = realLineTypes(sourceDdl, targetDdl);
assert.equal(types.get("left:`created_at` bigint(20) DEFAULT NULL,"), "equal");
assert.equal(types.get("right:`created_at` bigint(20) DEFAULT NULL,"), "equal");
});
test("shows a moved field as modified when its definition also changes", () => {
const source = ["`created_at` bigint(20) DEFAULT NULL,", "`name` varchar(64) NOT NULL"].join("\n");
const target = ["`name` varchar(64) NOT NULL", "`created_at` bigint(20) NOT NULL"].join("\n");
const hunks = buildHunks(source, target);
const createdAtLines = hunks.flatMap((hunk) => [...hunk.leftLines, ...hunk.rightLines]).filter((line) => !line.isPadding && line.content.includes("`created_at`"));
assert.deepEqual(
createdAtLines.map((line) => line.type),
["modify", "modify"],
);
assert.deepEqual(
createdAtLines.map((line) => line.comparisonContent),
["`created_at` bigint(20) NOT NULL", "`created_at` bigint(20) DEFAULT NULL,"],
);
});
test.each([
["MySQL", "`name`", "`status`"],
["PostgreSQL", '"name"', '"status"'],
["SQL Server", "[name]", "[status]"],
])("does not mark different %s column identifiers as modified", (_database, sourceColumn, targetColumn) => {
const hunks = buildHunks(`${sourceColumn} varchar(64) NOT NULL`, `${targetColumn} varchar(64) NOT NULL`);
assert.equal(
hunks.some((hunk) => hunk.type === "modify"),
false,
);
assert.deepEqual(
hunks.map((hunk) => hunk.type),
["delete", "insert"],
);
});
test("keeps a same-name constraint aligned when its definition changes", () => {
const source = ' CONSTRAINT "fk_orders_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id")';
const target = ' CONSTRAINT "fk_orders_user" FOREIGN KEY ("user_id") REFERENCES "accounts" ("id")';
const hunks = buildHunks(source, target);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].type, "modify");
});
test("does not align different named indexes by their shared definition", () => {
for (const [source, target] of [
[" UNIQUE KEY `idx_users_email` (`email`)", " UNIQUE KEY `idx_accounts_email` (`email`)"],
[" FULLTEXT KEY `idx_users_bio` (`bio`)", " FULLTEXT KEY `idx_accounts_bio` (`bio`)"],
['CREATE UNIQUE INDEX "idx_users_email" ON "users" ("email")', 'CREATE UNIQUE INDEX "idx_accounts_email" ON "users" ("email")'],
]) {
const hunks = buildHunks(source, target);
assert.equal(
hunks.some((hunk) => hunk.type === "modify"),
false,
);
}
});
test("distinguishes quoted keyword columns from SQL clause lines", () => {
const columnHunks = buildHunks(' "select" varchar(64)', ' "from" varchar(64)');
const clauseHunks = buildHunks("SELECT name", "FROM users");
assert.equal(
columnHunks.some((hunk) => hunk.type === "modify"),
false,
);
assert.equal(
clauseHunks.some((hunk) => hunk.type === "modify"),
false,
);
});
test("keeps large rewritten DDL blocks bounded and identity-safe", () => {
const source = Array.from({ length: 600 }, (_, index) => ` \`source_${index}\` varchar(255) DEFAULT NULL`).join("\n");
const target = Array.from({ length: 600 }, (_, index) => ` \`target_${index}\` varchar(255) DEFAULT NULL`).join("\n");
const hunks = buildHunks(source, target);
assert.equal(
hunks.some((hunk) => hunk.type === "modify"),
false,
);
});
+185 -5
View File
@@ -1,6 +1,17 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { buildDeploySqlForObjects, convertToSchemaDiffObjects, schemaDiffDeployTargetSchema, type TableDiff } from "../../apps/desktop/src/lib/schema/schemaDiff.ts";
import {
buildDeploySqlForObjects,
convertToSchemaDiffObjects,
detectDestructiveSchemaDiffStatements,
groupDiffObjects,
schemaDiffDeployTargetSchema,
schemaDiffReviewAlert,
schemaDiffSelectionOwnerId,
setSchemaDiffObjectSelected,
summarizeSchemaDiffOperations,
type TableDiff,
} from "../../apps/desktop/src/lib/schema/schemaDiff.ts";
test("uses generated sync SQL for modified table deployment", () => {
const tableDiffs: TableDiff[] = [
@@ -23,10 +34,7 @@ test("uses generated sync SQL for modified table deployment", () => {
const objects = convertToSchemaDiffObjects(tableDiffs);
const deploySql = buildDeploySqlForObjects(objects);
assert.equal(
deploySql,
"-- Alter table: users\nALTER TABLE `users`\n MODIFY COLUMN `name` varchar(128) NOT NULL;\n",
);
assert.equal(deploySql, "-- Alter table: users\nALTER TABLE `users`\n MODIFY COLUMN `name` varchar(128) NOT NULL;\n");
assert.equal(deploySql.includes("CREATE TABLE"), false);
});
@@ -51,3 +59,175 @@ test("uses mysql target database as schema diff deploy qualifier", () => {
assert.equal(schemaDiffDeployTargetSchema("mysql", "target_db", "explicit_schema"), "explicit_schema");
assert.equal(schemaDiffDeployTargetSchema("sqlite", "main", ""), undefined);
});
test("shows a modified table with a removed index once in the delete group", () => {
const objects = convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "users",
ddl: "CREATE TABLE `users` (`email` varchar(255), KEY `idx_users_email` (`email`));",
targetDdl: "CREATE TABLE `users` (`email` varchar(255));",
syncSql: "DROP INDEX `idx_users_email` ON `users`;",
indexes: [{ type: "removed", name: "idx_users_email" }],
},
]);
const deleteGroup = groupDiffObjects(objects).find((group) => group.operationType === "delete");
const tables = deleteGroup?.typeGroups.find((group) => group.kind === "table")?.objects ?? [];
assert.equal(tables.length, 1);
assert.equal(tables[0].name, "users");
assert.equal(tables[0].sourceDdl, objects[0].sourceDdl);
assert.equal(tables[0].targetDdl, objects[0].targetDdl);
assert.equal(schemaDiffSelectionOwnerId(tables[0]), objects[0].id);
assert.equal(deleteGroup?.count, 1);
assert.equal(summarizeSchemaDiffOperations(objects).delete, 1);
});
test("keeps ordinary child changes under the modified table while surfacing delete risks", () => {
const objects = convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "users",
syncSql: "ALTER TABLE `users` MODIFY COLUMN `name` varchar(128), ADD COLUMN `nickname` varchar(64), DROP INDEX `idx_legacy`;",
columns: [
{ type: "modified", name: "name" },
{ type: "added", name: "nickname" },
],
indexes: [{ type: "removed", name: "idx_legacy" }],
},
]);
const groups = groupDiffObjects(objects);
const modifyGroup = groups.find((group) => group.operationType === "modify");
const createGroup = groups.find((group) => group.operationType === "create");
const deleteGroup = groups.find((group) => group.operationType === "delete");
assert.deepEqual(
modifyGroup?.typeGroups.map((group) => ({ kind: group.kind, names: group.objects.map((object) => object.name) })),
[{ kind: "table", names: ["users"] }],
);
assert.equal(createGroup?.count, 0);
assert.deepEqual(
deleteGroup?.typeGroups.map((group) => ({ kind: group.kind, names: group.objects.map((object) => object.name) })),
[{ kind: "table", names: ["users"] }],
);
assert.deepEqual(summarizeSchemaDiffOperations(objects), { create: 0, modify: 1, delete: 1, none: 0 });
});
test("surfaces a modified index as delete risk because deployment drops it first", () => {
const objects = convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "users",
syncSql: "DROP INDEX `idx_users_email` ON `users`;\nCREATE UNIQUE INDEX `idx_users_email` ON `users` (`email`);",
indexes: [{ type: "modified", name: "idx_users_email", changes: ["unique: NO -> YES"] }],
},
]);
const index = objects[0].children?.find((child) => child.objectKind === "index");
assert.equal(index?.operationType, "delete");
assert.equal(summarizeSchemaDiffOperations(objects).delete, 1);
});
test("surfaces a modified foreign key as delete risk because deployment drops it first", () => {
const objects = convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "orders",
syncSql: "ALTER TABLE `orders` DROP FOREIGN KEY `fk_orders_user`;",
foreignKeys: [{ type: "modified", name: "fk_orders_user", changes: ["onDelete: RESTRICT -> CASCADE"] }],
},
]);
const foreignKey = objects[0].children?.find((child) => child.objectKind === "foreignKey");
assert.equal(foreignKey?.operationType, "delete");
assert.equal(summarizeSchemaDiffOperations(objects).delete, 1);
});
test("counts multiple destructive child changes on one modified table once", () => {
const objects = convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "orders",
syncSql: "ALTER TABLE `orders` DROP COLUMN `legacy_code`, DROP INDEX `idx_legacy`, DROP FOREIGN KEY `fk_legacy`;",
columns: [{ type: "removed", name: "legacy_code" }],
indexes: [{ type: "removed", name: "idx_legacy" }],
foreignKeys: [{ type: "removed", name: "fk_legacy" }],
},
]);
const deleteGroup = groupDiffObjects(objects).find((group) => group.operationType === "delete");
assert.equal(deleteGroup?.count, 1);
assert.deepEqual(
deleteGroup?.typeGroups.map((group) => group.kind),
["table"],
);
assert.deepEqual(summarizeSchemaDiffOperations(objects), { create: 0, modify: 1, delete: 1, none: 0 });
});
test("does not double count children when an entire table is deleted", () => {
const objects = convertToSchemaDiffObjects([
{
type: "removed",
objectType: "table",
name: "legacy_users",
columns: [{ type: "removed", name: "id" }],
indexes: [{ type: "removed", name: "idx_legacy_users_id" }],
},
]);
const counts = summarizeSchemaDiffOperations(objects);
const deleteGroup = groupDiffObjects(objects).find((group) => group.operationType === "delete");
assert.equal(counts.delete, 1);
assert.equal(deleteGroup?.count, 1);
assert.deepEqual(
deleteGroup?.typeGroups.map((group) => group.kind),
["table"],
);
});
test("clearing an index clears its atomic table deploy unit", () => {
const objects = convertToSchemaDiffObjects([
{
type: "modified",
objectType: "table",
name: "users",
syncSql: "ALTER TABLE `users` DROP INDEX `idx_users_email`, ADD COLUMN `nickname` varchar(64);",
columns: [{ type: "added", name: "nickname" }],
indexes: [{ type: "removed", name: "idx_users_email" }],
},
]);
assert.equal(setSchemaDiffObjectSelected(objects, "idx-users-idx_users_email", false), true);
assert.equal(objects[0].selected, false);
assert.equal(
objects[0].children?.every((child) => !child.selected),
true,
);
assert.equal(buildDeploySqlForObjects(objects), "-- No objects selected");
});
test("detects destructive schema diff statements without comment or string false positives", () => {
const destructive = detectDestructiveSchemaDiffStatements(
["-- DROP TABLE audit_log", "SELECT 'DROP INDEX idx_fake' AS message", 'ALTER TABLE "DROP INDEX audit" ADD COLUMN note text', "DROP INDEX IF EXISTS idx_users_email", "ALTER TABLE users DROP COLUMN legacy_code, DROP INDEX idx_legacy"].join(";\n"),
"postgres",
);
assert.deepEqual(
destructive.map(({ objectType }) => objectType),
["INDEX", "COLUMN", "INDEX"],
);
});
test("does not classify compatibility warnings as destructive when no delete SQL is selected", () => {
assert.equal(schemaDiffReviewAlert(0, 58), "compatibility");
assert.equal(schemaDiffReviewAlert(1, 58), "destructive");
assert.equal(schemaDiffReviewAlert(0, 0), null);
});
+84 -3
View File
@@ -295,8 +295,17 @@ pub async fn execute_script_with_2pc_core(
database: String,
statements: Vec<String>,
schema: Option<String>,
destructive_confirmed: bool,
) -> dbx_core::query::SchemaDiffDeployResult {
dbx_core::query::execute_schema_diff_deploy(&app, &connection_id, &database, &statements, schema.as_deref()).await
dbx_core::query::execute_schema_diff_deploy(
&app,
&connection_id,
&database,
&statements,
schema.as_deref(),
destructive_confirmed,
)
.await
}
#[tauri::command]
@@ -306,9 +315,18 @@ pub async fn execute_script_with_2pc(
database: String,
statements: Vec<String>,
schema: Option<String>,
destructive_confirmed: Option<bool>,
) -> Result<dbx_core::query::SchemaDiffDeployResult, String> {
let app: Arc<AppState> = (*state).clone();
Ok(execute_script_with_2pc_core(app, connection_id, database, statements, schema).await)
Ok(execute_script_with_2pc_core(
app,
connection_id,
database,
statements,
schema,
destructive_confirmed.unwrap_or(false),
)
.await)
}
#[tauri::command]
@@ -772,6 +790,7 @@ mod tests {
"testdb".to_string(),
vec!["SELECT 1".to_string()],
None,
false,
)
.await;
@@ -788,7 +807,8 @@ mod tests {
async fn execute_script_with_2pc_empty_statements_succeeds() {
let state = test_app_state().await;
let result =
execute_script_with_2pc_core(state, "conn-empty".to_string(), "testdb".to_string(), vec![], None).await;
execute_script_with_2pc_core(state, "conn-empty".to_string(), "testdb".to_string(), vec![], None, false)
.await;
assert_eq!(result.status, "committed");
assert_eq!(result.statement_count, 0);
@@ -806,6 +826,7 @@ mod tests {
"testdb".to_string(),
vec!["-- WARNING: incomplete\n-- manual only".to_string()],
None,
false,
)
.await;
@@ -823,6 +844,7 @@ mod tests {
"testdb".to_string(),
vec!["CREATE TABLE t1 (id INT)".to_string(), "CREATE TABLE t2 (id INT)".to_string()],
None,
false,
)
.await;
@@ -831,4 +853,63 @@ mod tests {
assert!(result.error.as_ref().is_some_and(|e| !e.is_empty()));
assert_eq!(result.executed_count, 0);
}
#[tokio::test]
async fn execute_script_with_2pc_blocks_unconfirmed_destructive_sql() {
let state = test_app_state().await;
let result = execute_script_with_2pc_core(
state,
"missing-conn".to_string(),
"testdb".to_string(),
vec!["DROP INDEX idx_old ON users".to_string()],
None,
false,
)
.await;
assert_eq!(result.status, "rolled_back");
assert_eq!(result.executed_count, 0);
assert_eq!(result.metadata["blocked"], "destructive_confirmation_required");
assert_eq!(result.metadata["destructive_statement_count"], 1);
}
#[tokio::test]
async fn execute_script_with_2pc_allows_confirmed_destructive_sql_to_reach_execution() {
let state = test_app_state().await;
let result = execute_script_with_2pc_core(
state,
"missing-conn".to_string(),
"testdb".to_string(),
vec!["DROP INDEX idx_old ON users".to_string()],
None,
true,
)
.await;
assert_ne!(
result.metadata.get("blocked").and_then(|value| value.as_str()),
Some("destructive_confirmation_required")
);
assert!(result.error.as_ref().is_some_and(|error| !error.is_empty()));
}
#[tokio::test]
async fn execute_script_with_2pc_does_not_block_drop_text_in_comments() {
let state = test_app_state().await;
let result = execute_script_with_2pc_core(
state,
"missing-conn".to_string(),
"testdb".to_string(),
vec!["-- DROP INDEX idx_fake\nSELECT 1".to_string()],
None,
false,
)
.await;
assert_ne!(
result.metadata.get("blocked").and_then(|value| value.as_str()),
Some("destructive_confirmation_required")
);
assert!(result.error.as_ref().is_some_and(|error| !error.is_empty()));
}
}