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