diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 921aedb2d..98103e30e 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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 { + 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) { diff --git a/apps/desktop/src/lib/__tests__/tableDependencySort.spec.ts b/apps/desktop/src/lib/__tests__/tableDependencySort.spec.ts new file mode 100644 index 000000000..bc72c1ced --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tableDependencySort.spec.ts @@ -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[] = []): 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")); + }); +}); diff --git a/apps/desktop/src/lib/tableDependencySort.ts b/apps/desktop/src/lib/tableDependencySort.ts new file mode 100644 index 000000000..39333e434 --- /dev/null +++ b/apps/desktop/src/lib/tableDependencySort.ts @@ -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(); + const inDegree = new Map(); + + 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)!); +} diff --git a/docs/app/global.css b/docs/app/global.css index c960de6af..9528e043f 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -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; diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/docs/next-env.d.ts +++ b/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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.