mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(schema-diff): guard and review destructive DDL
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "トリガー",
|
||||
|
||||
@@ -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: "트리거",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "触发器",
|
||||
|
||||
@@ -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: "確認部署",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user