From f664918e958e2ba0906fe8686c96b9eb83dcd482 Mon Sep 17 00:00:00 2001 From: skyler <1156263951@qq.com> Date: Sun, 30 Aug 2026 10:35:08 +0800 Subject: [PATCH] fix(schema-diff): guard deploy script execution --- .../components/diff/SchemaDiffDdlPanel.vue | 4 -- .../src/components/diff/SchemaDiffDialog.vue | 24 ++++--- .../diff/__tests__/SchemaDiffDdlPanel.spec.ts | 8 +++ .../diff/__tests__/SchemaDiffDialog.spec.ts | 2 + .../schema/schemaDiffSelection.spec.ts | 35 +++++++++- apps/desktop/src/lib/schema/schemaDiff.ts | 70 +++++++++++++++++-- 6 files changed, 125 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/components/diff/SchemaDiffDdlPanel.vue b/apps/desktop/src/components/diff/SchemaDiffDdlPanel.vue index 84f0f273c4..c5bd02857d 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDdlPanel.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDdlPanel.vue @@ -465,10 +465,6 @@ function copyDeploySqlAll() { {{ t("diff.copy") }} -
diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index c486059f61..74b0c6d76e 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -51,6 +51,7 @@ import { type CompatibilityWarning, type PermissionDiff, type DependencyGraph, + normalizeSchemaDiffDependencyGraph, } from "@/lib/schema/schemaDiff"; import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter"; import { Splitpanes, Pane } from "splitpanes"; @@ -593,7 +594,7 @@ async function handleCompare() { renameCandidates.value = result.renameCandidates ?? []; compatibilityWarnings.value = result.compatibilityWarnings ?? []; permissionDiffs.value = result.permissionDiffs ?? []; - dependencyGraph.value = result.dependencyGraph ?? null; + dependencyGraph.value = normalizeSchemaDiffDependencyGraph(result.dependencyGraph); // Convert to unified objects diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs, result.renameCandidates); @@ -686,13 +687,19 @@ function formatSchemaSyncPlan(plan: Awaited { if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") { return false; } - return true; + const sql = selectedDeploySql.value.trim(); + return sql.length > 0 && sql !== "-- No objects selected"; }); const destructiveStatements = computed(() => { diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffDdlPanel.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffDdlPanel.spec.ts index f280fb0289..65b0165e29 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffDdlPanel.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffDdlPanel.spec.ts @@ -18,4 +18,12 @@ describe("SchemaDiffDdlPanel diff highlighting", () => { expect(panelSource).toContain("rollbackForwardSql?: string;"); expect(panelSource).toContain("props.rollbackForwardSql ?? props.deploySql"); }); + + it("does not expose selected-SQL execution from the focused script tab", () => { + const focusedScript = panelSource.slice(panelSource.indexOf(""), panelSource.indexOf("")); + const selectedScript = panelSource.slice(panelSource.indexOf("")); + + expect(focusedScript).not.toContain("$emit('executeScript')"); + expect(selectedScript).toContain("$emit('executeScript')"); + }); }); diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts index 56128a8b60..95920f4a03 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts @@ -120,5 +120,7 @@ describe("SchemaDiffDialog fullscreen layout", () => { expect(dialogSource).toContain("sql: selectedDeploySql.value"); expect(dialogSource).toContain("[selectedDeploySql.value]"); expect(dialogSource).toContain("selectedObjectId.value !== objectId"); + expect(dialogSource).toContain('selectedDeploySql.value = "";'); + expect(dialogSource).toContain("const sql = selectedDeploySql.value.trim();"); }); }); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts index fb1a0a3215..d03ba4c85c 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffSelection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { convertToSchemaDiffObjects, selectSchemaDiffInput, selectSchemaDiffInputForObject, setSchemaDiffObjectSelected, type SchemaDiffPreparation } from "../../schema/schemaDiff"; +import { convertToSchemaDiffObjects, normalizeSchemaDiffDependencyGraph, selectSchemaDiffInput, selectSchemaDiffInputForObject, setSchemaDiffObjectSelected, type SchemaDiffPreparation } from "../../schema/schemaDiff"; function createPreparation(): SchemaDiffPreparation { return { @@ -74,4 +74,37 @@ describe("schema diff SQL selection projections", () => { ownerDiffs: [], }); }); + + it("includes the complete predecessor closure in focused table SQL", () => { + const result = createPreparation(); + result.diffs.push({ + type: "added", + objectType: "table", + name: "table_c", + }); + result.dependencyGraph = { + nodes: [ + { tableName: "table_a", dependsOn: ["table_b"], dependedBy: [] }, + { tableName: "table_b", dependsOn: ["table_c"], dependedBy: ["table_a"] }, + { tableName: "table_c", dependsOn: [], dependedBy: ["table_b"] }, + ], + }; + const objects = convertToSchemaDiffObjects(result.diffs); + + const focusedInput = selectSchemaDiffInputForObject(result, objects, "table-table_a"); + + expect(focusedInput.diffs.map((diff) => diff.name)).toEqual(["table_a", "table_b", "table_c"]); + }); + + it("normalizes the Rust dependency graph map and snake-case fields", () => { + expect( + normalizeSchemaDiffDependencyGraph({ + nodes: { + table_a: { table_name: "table_a", depends_on: ["table_b"], depended_by: [] }, + }, + }), + ).toEqual({ + nodes: [{ tableName: "table_a", dependsOn: ["table_b"], dependedBy: [] }], + }); + }); }); diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts index df7a72aaf4..766f49cfb5 100644 --- a/apps/desktop/src/lib/schema/schemaDiff.ts +++ b/apps/desktop/src/lib/schema/schemaDiff.ts @@ -264,6 +264,42 @@ export interface DependencyGraph { nodes: DependencyNode[]; } +interface RawSchemaDiffDependencyNode { + tableName?: unknown; + table_name?: unknown; + dependsOn?: unknown; + depends_on?: unknown; + dependedBy?: unknown; + depended_by?: unknown; +} + +interface RawSchemaDiffDependencyGraph { + nodes?: RawSchemaDiffDependencyNode[] | Record; +} + +export function normalizeSchemaDiffDependencyGraph(graph: unknown): DependencyGraph | null { + if (!graph || typeof graph !== "object") return null; + + const rawNodes = (graph as RawSchemaDiffDependencyGraph).nodes; + const entries = Array.isArray(rawNodes) ? rawNodes : rawNodes && typeof rawNodes === "object" ? Object.values(rawNodes) : []; + const nodes = entries.flatMap((node) => { + const tableName = typeof node.tableName === "string" ? node.tableName : typeof node.table_name === "string" ? node.table_name : ""; + if (!tableName) return []; + + const dependsOn = Array.isArray(node.dependsOn) ? node.dependsOn : Array.isArray(node.depends_on) ? node.depends_on : []; + const dependedBy = Array.isArray(node.dependedBy) ? node.dependedBy : Array.isArray(node.depended_by) ? node.depended_by : []; + return [ + { + tableName, + dependsOn: dependsOn.filter((name): name is string => typeof name === "string"), + dependedBy: dependedBy.filter((name): name is string => typeof name === "string"), + }, + ]; + }); + + return nodes.length > 0 ? { nodes } : null; +} + export interface MissingRollbackObject { kind: string; name: string; @@ -802,23 +838,47 @@ export function selectSchemaDiffInput(result: SchemaDiffPreparation, objects: Sc }; } -function projectSchemaDiffObjectSelection(object: SchemaDiffObject, focusedObjectId: string, selectDescendants: boolean): SchemaDiffObject { +function projectSchemaDiffObjectSelection(object: SchemaDiffObject, focusedObjectId: string, selectDescendants: boolean, dependencyNames: ReadonlySet): SchemaDiffObject { const isFocused = object.id === focusedObjectId; - const selectChildren = selectDescendants || isFocused; + const isDependency = !object.parentId && dependencyNames.has(object.name); + const selectChildren = selectDescendants || isFocused || isDependency; return { ...object, - selected: isFocused || selectDescendants, - children: object.children?.map((child) => projectSchemaDiffObjectSelection(child, focusedObjectId, selectChildren)), + selected: isFocused || selectDescendants || isDependency, + children: object.children?.map((child) => projectSchemaDiffObjectSelection(child, focusedObjectId, selectChildren, dependencyNames)), }; } +function schemaDiffDependencyClosure(result: SchemaDiffPreparation, objects: SchemaDiffObject[], objectId: string): Set { + const focusedObject = findSchemaDiffObject(objects, objectId); + if (!focusedObject) return new Set(); + + const rootObject = focusedObject.parentId ? findSchemaDiffObject(objects, focusedObject.parentId) : focusedObject; + if (!rootObject) return new Set(); + + const dependencyNodes = new Map((normalizeSchemaDiffDependencyGraph(result.dependencyGraph)?.nodes ?? []).map((node) => [node.tableName, node])); + const dependencies = new Set(); + const pending = [rootObject.name]; + while (pending.length > 0) { + const name = pending.pop(); + if (!name) continue; + for (const dependency of dependencyNodes.get(name)?.dependsOn ?? []) { + if (dependency === rootObject.name || dependencies.has(dependency)) continue; + dependencies.add(dependency); + pending.push(dependency); + } + } + return dependencies; +} + /** Project one focused tree object onto a fresh selection without mutating checkbox state. */ export function selectSchemaDiffInputForObject(result: SchemaDiffPreparation, objects: SchemaDiffObject[], objectId: string): SelectedSchemaDiffInput { if (!findSchemaDiffObject(objects, objectId)) { return { diffs: [], functionDiffs: [], sequenceDiffs: [], ruleDiffs: [], ownerDiffs: [] }; } - const focusedSelection = objects.map((object) => projectSchemaDiffObjectSelection(object, objectId, false)); + const dependencyNames = schemaDiffDependencyClosure(result, objects, objectId); + const focusedSelection = objects.map((object) => projectSchemaDiffObjectSelection(object, objectId, false, dependencyNames)); return selectSchemaDiffInput(result, focusedSelection); }