fix(desktop): sort batch table drop by foreign key dependency
This commit is contained in:
parent
a1feda0c6d
commit
8d4a08c3d5
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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)!);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue