fix(desktop): sort batch table drop by foreign key dependency

This commit is contained in:
t8y2 2026-06-16 15:01:21 +08:00
parent a1feda0c6d
commit 8d4a08c3d5
5 changed files with 196 additions and 4 deletions

View File

@ -44,7 +44,8 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import * as api from "@/lib/api";
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
import type { ConnectionConfig, ForeignKeyInfo, ObjectInfo, ObjectSourceKind } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/tableDependencySort";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/databaseFeatureSupport";
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect";
@ -657,9 +658,27 @@ function openBatchDatabaseExport() {
};
}
async function fetchSortedTableRowsForDrop(): Promise<ObjectBrowserRow[]> {
const rows = [...selectedTableRows.value];
if (rows.length <= 1) return rows;
const fkResults = await Promise.all(rows.map((row) => api.listForeignKeys(props.connection.id, props.database, row.schema || selectedSchema.value || "", row.name).catch(() => [] as ForeignKeyInfo[])));
const tablesWithFk: TableWithFk[] = rows.map((row, i) => ({
name: row.name,
schema: row.schema || selectedSchema.value,
foreignKeys: fkResults[i] ?? [],
}));
const sorted = sortTablesByFkDependency(tablesWithFk);
const nameToRow = new Map(rows.map((r) => [r.name, r]));
return sorted.map((t) => nameToRow.get(t.name)!).filter(Boolean);
}
async function refreshBatchDropPreviewSql() {
const statements: string[] = [];
for (const row of selectedTableRows.value) {
const sortedRows = await fetchSortedTableRowsForDrop();
for (const row of sortedRows) {
const sql = await buildDropObjectSql({
databaseType: effectiveDatabaseType.value,
objectType: "TABLE",
@ -679,7 +698,7 @@ function requestBatchDropTables() {
}
async function confirmBatchDropTables() {
const targets = [...selectedTableRows.value];
const targets = await fetchSortedTableRowsForDrop();
if (targets.length === 0) return;
try {
for (const row of targets) {

View File

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/tableDependencySort";
function fk(refTable: string) {
return { name: `fk_to_${refTable}`, column: "id", ref_table: refTable, ref_column: "id" };
}
function table(name: string, foreignKeys: ReturnType<typeof fk>[] = []): TableWithFk {
return { name, foreignKeys };
}
describe("sortTablesByFkDependency", () => {
it("returns single table unchanged", () => {
const input = [table("t1")];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t1"]);
});
it("returns empty array unchanged", () => {
expect(sortTablesByFkDependency([])).toEqual([]);
});
it("preserves original order when no FK dependencies exist", () => {
const input = [table("t2"), table("t1")];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t2", "t1"]);
});
it("sorts referencing table before referenced table (simple dependency)", () => {
// t2 references t1 → t2 should be dropped before t1
const input = [table("t1"), table("t2", [fk("t1")])];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t2", "t1"]);
});
it("sorts referencing table before referenced table regardless of input order", () => {
// Input order reversed from the above test
const input = [table("t2", [fk("t1")]), table("t1")];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t2", "t1"]);
});
it("handles chain dependency (C→B→A)", () => {
// t3 references t2, t2 references t1 → t3, t2, t1
const input = [table("t1"), table("t2", [fk("t1")]), table("t3", [fk("t2")])];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t3", "t2", "t1"]);
});
it("handles multiple tables referencing the same table", () => {
// t2 and t3 both reference t1 → t2,t3 before t1
const input = [table("t1"), table("t2", [fk("t1")]), table("t3", [fk("t1")])];
const result = sortTablesByFkDependency(input);
const names = result.map((t) => t.name);
// t1 must be last
expect(names[names.length - 1]).toBe("t1");
// t2 and t3 must come before t1
expect(names.slice(0, 2)).toEqual(expect.arrayContaining(["t2", "t3"]));
});
it("falls back to original order on cyclic dependency", () => {
// t1 references t2 and t2 references t1
const input = [table("t1", [fk("t2")]), table("t2", [fk("t1")])];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t1", "t2"]);
});
it("ignores FK references to tables not in the selected set", () => {
// t2 references t3 (not selected), t1 has no FK → original order
const input = [table("t1"), table("t2", [fk("t3")])];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t1", "t2"]);
});
it("handles table with multiple FKs where only some target selected tables", () => {
// t3 references both t1 (selected) and t4 (not selected)
const input = [table("t1"), table("t3", [fk("t1"), fk("t4")])];
const result = sortTablesByFkDependency(input);
expect(result.map((t) => t.name)).toEqual(["t3", "t1"]);
});
it("handles complex DAG with multiple branches", () => {
// t4 → t2 → t1
// t3 → t1
const input = [table("t1"), table("t2", [fk("t1")]), table("t3", [fk("t1")]), table("t4", [fk("t2")])];
const result = sortTablesByFkDependency(input);
const names = result.map((t) => t.name);
// t1 must be last (everything references it directly or indirectly)
expect(names[names.length - 1]).toBe("t1");
// t4 must come before t2
expect(names.indexOf("t4")).toBeLessThan(names.indexOf("t2"));
// t2 and t3 must come before t1
expect(names.indexOf("t2")).toBeLessThan(names.indexOf("t1"));
expect(names.indexOf("t3")).toBeLessThan(names.indexOf("t1"));
});
});

View File

@ -0,0 +1,73 @@
import type { ForeignKeyInfo } from "@/types/database";
export interface TableWithFk {
name: string;
schema?: string | null;
foreignKeys: ForeignKeyInfo[];
}
/**
* Sort tables by foreign key dependency so that referencing tables
* (those with an FK pointing to another table in the list) come before
* the tables they reference.
*
* Uses Kahn's algorithm for topological sort. Falls back to original
* order when a cycle is detected.
*/
export function sortTablesByFkDependency(tables: TableWithFk[]): TableWithFk[] {
if (tables.length <= 1) return tables;
const nameSet = new Set(tables.map((t) => t.name));
const adjacency = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const table of tables) {
if (!adjacency.has(table.name)) adjacency.set(table.name, []);
if (!inDegree.has(table.name)) inDegree.set(table.name, 0);
}
// Build dependency graph: an edge A → B means A references B,
// so A should be dropped before B.
for (const table of tables) {
for (const fk of table.foreignKeys) {
if (nameSet.has(fk.ref_table)) {
// A → B: A depends on B, drop A first
adjacency.get(table.name)!.push(fk.ref_table);
inDegree.set(fk.ref_table, (inDegree.get(fk.ref_table) ?? 0) + 1);
}
}
}
// Kahn topological sort
const queue: string[] = [];
for (const [name, degree] of inDegree) {
if (degree === 0) queue.push(name);
}
const sortedNames: string[] = [];
while (queue.length > 0) {
// Sort queue for deterministic output; preserves original order
// among nodes with equal in-degree.
queue.sort((a, b) => {
const ia = tables.findIndex((t) => t.name === a);
const ib = tables.findIndex((t) => t.name === b);
return ia - ib;
});
const current = queue.shift()!;
sortedNames.push(current);
for (const neighbor of adjacency.get(current) ?? []) {
const newDegree = (inDegree.get(neighbor) ?? 1) - 1;
inDegree.set(neighbor, newDegree);
if (newDegree === 0) queue.push(neighbor);
}
}
// If not all nodes sorted, cycle detected — fall back to original order
if (sortedNames.length !== tables.length) {
return tables;
}
const nameToTable = new Map(tables.map((t) => [t.name, t]));
return sortedNames.map((name) => nameToTable.get(name)!);
}

View File

@ -219,6 +219,10 @@ article figure.shiki pre code {
line-height: 1.58;
}
article figure.shiki button:hover {
color: #e5e5e5;
}
article :not(pre) > code {
border: 1px solid var(--dbx-line);
border-radius: 6px;

2
docs/next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.