From 8ddf979cfe7e6a8e96ba12f922781616b351fd95 Mon Sep 17 00:00:00 2001 From: Budi Santoso Date: Mon, 9 Nov 2026 17:05:59 +0700 Subject: [PATCH] feat(db): separate procedures and functions into their own schema folders (#2270) The database tree lists routines in one `routines` folder. A Separate Procedures and Functions option in the Database tool window's menu (off by default, remembered across sessions) lists `procedures` and `functions` folders instead, each holding only routines of that kind, with the same overload-aware keys and object filter. Expanded folders carry over when the option is switched either way. Co-authored-by: boenkkk <17746937+boenkkk@users.noreply.github.com> --- crates/ide-db/src/lib.rs | 2 +- docs/databases.md | 9 +- src/bindings.ts | 2 +- src/features/database/DatabaseView.test.tsx | 108 ++++++++++++++++++++ src/features/database/DatabaseView.tsx | 64 ++++++++---- src/features/database/index.ts | 8 +- src/features/database/nodes.test.ts | 17 +++ src/features/database/nodes.ts | 10 +- src/features/database/store.ts | 22 ++++ src/features/database/treeActions.ts | 6 ++ src/platform/locales/text/en.json | 1 + src/platform/locales/text/zh.json | 1 + 12 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 src/features/database/DatabaseView.test.tsx create mode 100644 src/features/database/nodes.test.ts diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index bab76ac77..56523ce27 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -92,7 +92,7 @@ pub struct SequenceInfo { pub data_type: String, } -/// A schema's routines and sequences (the tree's `routines` / `sequences` folders). +/// A schema's routines (procedures and functions) and sequences. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[serde(rename = "DbSchemaObjects")] diff --git a/docs/databases.md b/docs/databases.md index fb0c9258d..c8bbb7ae2 100644 --- a/docs/databases.md +++ b/docs/databases.md @@ -6,6 +6,11 @@ wire-compatible products listed in **New ▸ Data Source**. Connections are save per project in `.openidea/data-sources.toml`; passwords live outside the project (and never in that file). +The Database tree groups procedures and functions in one `routines` folder by +default. Enable **Separate Procedures and Functions** in the Database window’s +**Options** menu to show `procedures` and `functions` folders instead. This view +option is remembered across sessions. + ## Oracle: Oracle Instant Client Oracle support uses the Oracle Call Interface, so the Oracle client libraries @@ -50,8 +55,8 @@ search path. - Schemas = Oracle users (`ALL_USERS`); system accounts (`SYS`, `SYSTEM`, `XDB`, `MDSYS`…) appear only with **Schemas ▸ Show system**. -- Tables, views, columns, indexes, foreign keys, triggers, routines and - sequences are read from the `ALL_*` views. +- Tables, views, columns, indexes, foreign keys, triggers, procedures, functions + and sequences are read from the `ALL_*` views. - The query console runs SQL and PL/SQL blocks; `BEGIN … END;` blocks (nested `;` included) are sent as one statement, and a lone `/` on its own line ends one as in SQL*Plus. diff --git a/src/bindings.ts b/src/bindings.ts index 3a2f6a69b..5349a630a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3195,7 +3195,7 @@ export type DbRoutineInfo = { */ export type DbRowEdit = { kind: "update"; key: DbCellValue[]; values: DbCellValue[] } | { kind: "insert"; values: DbCellValue[] } | { kind: "delete"; key: DbCellValue[] }; -/** A schema's routines and sequences (the tree's `routines` / `sequences` folders). */ +/** A schema's routines (procedures and functions) and sequences. */ export type DbSchemaObjects = { routines: DbRoutineInfo[], sequences: DbSequenceInfo[], diff --git a/src/features/database/DatabaseView.test.tsx b/src/features/database/DatabaseView.test.tsx new file mode 100644 index 000000000..7c3c3e890 --- /dev/null +++ b/src/features/database/DatabaseView.test.tsx @@ -0,0 +1,108 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DatabaseView } from "./DatabaseView"; +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { registerDatabaseTreeActions } from "./treeActions"; +import { tablesKey, useDatabase } from "./store"; + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 24, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 24, key: index })), + scrollToIndex: () => {}, + measure: () => {}, + }), +})); + +let dispose: () => void; + +beforeEach(() => { + localStorage.removeItem("openidea.database.viewOptions"); + setContext("projectOpen", true); + dispose = registerDatabaseTreeActions(); + useDatabase.setState({ + loaded: true, + sources: [{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } }], + schemas: { pg: { status: "ok", value: ["public"] } }, + tables: { [tablesKey("pg", "public")]: { status: "ok", value: [] } }, + objects: { + [tablesKey("pg", "public")]: { + routines: [ + { name: "calculate", kind: "function", arguments: "integer", returns: "integer" }, + { name: "calculate", kind: "function", arguments: "text", returns: "text" }, + { name: "refresh_data", kind: "procedure", arguments: "", returns: null }, + ], + sequences: [], + }, + }, + treeFilter: "", + separateProceduresAndFunctions: false, + treeExpanded: new Set(["pg", "pg/public", "pg/public/routines"]), + }); +}); + +afterEach(() => dispose()); + +describe("routine grouping", () => { + it("keeps an existing expanded routines folder and both overloads by default", () => { + render(); + const tree = within(screen.getByRole("tree", { name: "Data sources" })); + expect(tree.getByText("routines").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "true"); + expect(tree.getAllByText("calculate")).toHaveLength(2); + expect(tree.getByText("refresh_data")).toBeInTheDocument(); + expect(tree.queryByText("procedures")).toBeNull(); + expect(tree.queryByText("functions")).toBeNull(); + fireEvent.mouseDown(tree.getAllByText("calculate")[1]!, { button: 0 }); + expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/routines/calculate(text)"); + act(() => useDatabase.setState({ treeFilter: "refresh" })); + expect(tree.queryAllByText("calculate")).toHaveLength(0); + expect(tree.getByText("refresh_data")).toBeInTheDocument(); + }); + + it("toggles through the action, persists the option, and restores the old expansion", async () => { + expect(useDatabase.getInitialState().separateProceduresAndFunctions).toBe(false); + render(); + const tree = within(screen.getByRole("tree", { name: "Data sources" })); + await act(() => executeAction("db.separateProceduresAndFunctions")); + expect(JSON.parse(localStorage.getItem("openidea.database.viewOptions")!)).toEqual({ + separateProceduresAndFunctions: true, + }); + expect(tree.queryByText("routines")).toBeNull(); + const procedures = tree.getByText("procedures").closest('[role="treeitem"]')!; + const functions = tree.getByText("functions").closest('[role="treeitem"]')!; + expect(within(procedures as HTMLElement).getByText("1")).toBeInTheDocument(); + expect(within(functions as HTMLElement).getByText("2")).toBeInTheDocument(); + fireEvent.mouseDown(functions.querySelector("[data-tree-toggle]")!, { button: 0 }); + expect(tree.getAllByText("calculate")).toHaveLength(2); + fireEvent.mouseDown(tree.getAllByText("calculate")[1]!, { button: 0 }); + expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/functions/calculate(text)"); + await act(() => executeAction("db.separateProceduresAndFunctions")); + expect(tree.getByText("routines").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "true"); + expect(tree.getAllByText("calculate")).toHaveLength(2); + expect(tree.getByText("refresh_data")).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem("openidea.database.viewOptions")!)).toEqual({ + separateProceduresAndFunctions: false, + }); + }); + + it.each([false, true])("applies object and quick filters before grouping (separate: %s)", (separate) => { + const source = useDatabase.getState().sources[0]!; + useDatabase.setState({ + separateProceduresAndFunctions: separate, + sources: [{ ...source, introspection: { objectFilter: "calculate" } }], + treeExpanded: new Set(["pg", "pg/public", "pg/public/routines", "pg/public/functions"]), + }); + render(); + const tree = within(screen.getByRole("tree", { name: "Data sources" })); + expect(tree.queryByText("procedures")).toBeNull(); + expect(tree.queryByText("refresh_data")).toBeNull(); + expect(tree.getAllByText("calculate")).toHaveLength(2); + const folder = tree.getByText(separate ? "functions" : "routines").closest('[role="treeitem"]')!; + expect(within(folder as HTMLElement).getByText("2")).toBeInTheDocument(); + act(() => useDatabase.setState({ treeFilter: "refresh" })); + expect(tree.queryByText("public")).toBeNull(); + expect(tree.queryByText("routines")).toBeNull(); + expect(tree.queryByText("functions")).toBeNull(); + }); +}); diff --git a/src/features/database/DatabaseView.tsx b/src/features/database/DatabaseView.tsx index 3af50928e..4d9cd8b46 100644 --- a/src/features/database/DatabaseView.tsx +++ b/src/features/database/DatabaseView.tsx @@ -1,7 +1,7 @@ /** * Database tool window (IDEA's layout): data source → database (PostgreSQL) → schema → `tables` / - * `views` / `materialized views` / `routines` / `sequences` folders → table → `columns` / `keys` / - * `foreign keys` / `indexes` / `triggers`, + * `views` / `materialized views` / `routines` (optionally `procedures` / `functions`) / `sequences` + * folders → table → `columns` / `keys` / `foreign keys` / `indexes` / `triggers`, * with counts and column types, loaded lazily. Double-click a table to open its data, a data * source to open its query console. */ @@ -32,7 +32,15 @@ import { splitTarget, targetOf, } from "./model"; -import { nodeTarget, schemaObjectOf, tableFolder, type DbNode as Node, type Folder, type Group } from "./nodes"; +import { + nodeTarget, + routineFolder, + schemaObjectOf, + tableFolder, + type DbNode as Node, + type Folder, + type Group, +} from "./nodes"; import { connect, detailsKey, @@ -66,10 +74,20 @@ const FOLDER_LABELS: Record = { views: "views", materializedViews: "materialized views", routines: "routines", + procedures: "procedures", + functions: "functions", sequences: "sequences", }; const FOLDERS: readonly Folder[] = ["tables", "views", "materializedViews", "routines", "sequences"]; +const SEPARATE_FOLDERS: readonly Folder[] = [ + "tables", + "views", + "materializedViews", + "procedures", + "functions", + "sequences", +]; function status(key: string, l: Loaded | undefined, map: (v: T) => Node[]): Node[] | undefined { if (!l) return undefined; @@ -101,6 +119,7 @@ function groupSize(d: DbTableDetails, g: Group): number { export function DatabaseView() { const st = useDatabase(); + const folders = st.separateProceduresAndFunctions ? SEPARATE_FOLDERS : FOLDERS; const tree = useRef(null); useEffect(() => { if (!st.loaded) void loadSources(); @@ -149,9 +168,12 @@ export function DatabaseView() { (st.objects[tablesKey(id, schema)]?.routines ?? []).filter(shown(id)); const sequencesOf = (id: string, schema: string) => (st.objects[tablesKey(id, schema)]?.sequences ?? []).filter(shown(id)); - /** Members of a schema folder: tables / views / materialized views, routines or sequences. */ + /** Members of a schema folder, after object and quick filtering. */ const folderSize = (id: string, schema: string, folder: Folder): number => { - if (folder === "routines") return routinesOf(id, schema).length; + if (folder === "routines" || folder === "procedures" || folder === "functions") + return routinesOf(id, schema).filter( + (routine) => routineFolder(routine.kind, st.separateProceduresAndFunctions) === folder, + ).length; if (folder === "sequences") return sequencesOf(id, schema).length; return (tablesOf(id, schema) ?? []).filter((t) => tableFolder(t.kind) === folder).length; }; @@ -205,24 +227,28 @@ export function DatabaseView() { return schemasOf(n.key, n.id); case "schema": return status(n.key, st.tables[tablesKey(n.id, n.schema)], () => - FOLDERS.filter((f) => folderSize(n.id, n.schema, f) > 0).map((folder): Node => ({ - kind: "folder", - key: `${n.key}/${folder}`, - id: n.id, - schema: n.schema, - folder, - })), + folders + .filter((f) => folderSize(n.id, n.schema, f) > 0) + .map((folder): Node => ({ + kind: "folder", + key: `${n.key}/${folder}`, + id: n.id, + schema: n.schema, + folder, + })), ); case "folder": { const at = { id: n.id, schema: n.schema }; - if (n.folder === "routines") + if (n.folder === "routines" || n.folder === "procedures" || n.folder === "functions") // Overloads share a name: the key tells them apart by their arguments. - return routinesOf(n.id, n.schema).map((routine) => ({ - kind: "routine", - key: `${n.key}/${routine.name}(${routine.arguments})`, - routine, - ...at, - })); + return routinesOf(n.id, n.schema) + .filter((routine) => routineFolder(routine.kind, st.separateProceduresAndFunctions) === n.folder) + .map((routine) => ({ + kind: "routine", + key: `${n.key}/${routine.name}(${routine.arguments})`, + routine, + ...at, + })); if (n.folder === "sequences") return sequencesOf(n.id, n.schema).map((sequence) => ({ kind: "sequence", diff --git a/src/features/database/index.ts b/src/features/database/index.ts index c6a0653d7..e9783c53c 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -4,7 +4,7 @@ * results grids, and a table data editor. Connections, introspection and * statements run in Rust (`ide-db`). */ -import { lazyComponent, lazyWhen } from "@/ui"; +import { lazyComponent, lazyWhen, type MenuItem } from "@/ui"; import { activeEditor, lazyEditorExtension, registerEditorContribution } from "@/editor/api"; import { tx } from "@/platform/i18n"; import { onIdeEvent } from "@/platform/ipc"; @@ -41,6 +41,11 @@ import { watchIdleConnections, } from "./store"; +function useDatabaseHeaderMenu(): MenuItem[] { + const separate = useDatabase((s) => s.separateProceduresAndFunctions); + return [{ kind: "action", action: "db.separateProceduresAndFunctions", checked: separate }]; +} + const useDialogOpen = () => useDatabase((s) => !!s.dialog); export function activate(): () => void { @@ -55,6 +60,7 @@ export function activate(): () => void { anchor: "right", order: 20, component: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseView)), + headerMenu: useDatabaseHeaderMenu, headerActions: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseHeaderActions)), }), registerServiceView({ diff --git a/src/features/database/nodes.test.ts b/src/features/database/nodes.test.ts new file mode 100644 index 000000000..f3fdbd4de --- /dev/null +++ b/src/features/database/nodes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { routineFolder, tableFolder } from "./nodes"; + +describe("schema folders", () => { + it("combines routines by default and separates them on request", () => { + expect(routineFolder("procedure")).toBe("routines"); + expect(routineFolder("function")).toBe("routines"); + expect(routineFolder("procedure", true)).toBe("procedures"); + expect(routineFolder("function", true)).toBe("functions"); + }); + + it("keeps tables, views and materialized views apart", () => { + expect(tableFolder("table")).toBe("tables"); + expect(tableFolder("view")).toBe("views"); + expect(tableFolder("materializedView")).toBe("materializedViews"); + }); +}); diff --git a/src/features/database/nodes.ts b/src/features/database/nodes.ts index 0aceff565..e670fce31 100644 --- a/src/features/database/nodes.ts +++ b/src/features/database/nodes.ts @@ -15,14 +15,20 @@ import type { DbObjectRef } from "./objects"; export type Group = "columns" | "keys" | "foreignKeys" | "indexes" | "triggers"; -/** Folders under a schema: IDEA's `tables`, `views`, `materialized views`, `routines`, `sequences`. */ -export type Folder = "tables" | "views" | "materializedViews" | "routines" | "sequences"; +/** Schema folders; routines can optionally be separated into procedures and functions. */ +export type Folder = "tables" | "views" | "materializedViews" | "routines" | "procedures" | "functions" | "sequences"; /** The folder a table, view or materialized view is listed in. */ export function tableFolder(kind: DbTableKind): Folder { return kind === "view" ? "views" : kind === "materializedView" ? "materializedViews" : "tables"; } +/** IDEA's *Separate Procedures and Functions* option: the folder a routine is listed in. */ +export function routineFolder(kind: DbRoutineInfo["kind"], separate = false): "routines" | "procedures" | "functions" { + if (!separate) return "routines"; + return kind === "procedure" ? "procedures" : "functions"; +} + /** Where a table-level node lives: connection target, schema, table. */ interface InTable { id: string; diff --git a/src/features/database/store.ts b/src/features/database/store.ts index 4adef497f..e44096551 100644 --- a/src/features/database/store.ts +++ b/src/features/database/store.ts @@ -57,6 +57,7 @@ export const DB_TOOL_WINDOW = "database"; export const RESULTS_VIEW = "dbResults"; const BINDINGS_KEY = "openidea.database.consoles"; const CONSOLE_SCHEMAS_KEY = "openidea.database.consoleSchemas"; +const VIEW_OPTIONS_KEY = "openidea.database.viewOptions"; const MAX_TABS = 12; export interface ResultTab { @@ -144,6 +145,7 @@ interface DatabaseState { consoleSchemas: Record; /** The Database tree's quick object filter (empty: everything shows). */ treeFilter: string; + separateProceduresAndFunctions: boolean; completion: Record; results: ResultTab[]; /** Result tab id, or `OUTPUT_TAB`. */ @@ -189,6 +191,15 @@ function readConsoleSchemas(): Record { } } +function readSeparateProceduresAndFunctions(): boolean { + try { + const raw = localStorage.getItem(VIEW_OPTIONS_KEY); + return raw ? JSON.parse(raw)?.separateProceduresAndFunctions === true : false; + } catch { + return false; + } +} + export const useDatabase = create(() => ({ sources: [], loaded: false, @@ -201,6 +212,7 @@ export const useDatabase = create(() => ({ consoles: readBindings(), consoleSchemas: readConsoleSchemas(), treeFilter: "", + separateProceduresAndFunctions: readSeparateProceduresAndFunctions(), completion: {}, results: [], activeResult: null, @@ -218,6 +230,16 @@ export const useDatabase = create(() => ({ pendingTx: new Set(), })); +export function toggleSeparateProceduresAndFunctions() { + const separateProceduresAndFunctions = !useDatabase.getState().separateProceduresAndFunctions; + useDatabase.setState({ separateProceduresAndFunctions }); + try { + localStorage.setItem(VIEW_OPTIONS_KEY, JSON.stringify({ separateProceduresAndFunctions })); + } catch { + // Per-viewer convenience only. + } +} + export const tablesKey = (id: string, schema: string) => `${id}\u0000${schema}`; export const detailsKey = (id: string, schema: string, table: string) => `${id}\u0000${schema}\u0000${table}`; diff --git a/src/features/database/treeActions.ts b/src/features/database/treeActions.ts index 5a902278b..7a43da172 100644 --- a/src/features/database/treeActions.ts +++ b/src/features/database/treeActions.ts @@ -32,6 +32,7 @@ import { runSql, sourceById, tablesKey, + toggleSeparateProceduresAndFunctions, useDatabase, withConnection, removeSource, @@ -462,6 +463,11 @@ export function registerDatabaseTreeActions(): () => void { useDatabase.setState({ newMenuOpen: true }); }, }), + tree({ + id: "db.separateProceduresAndFunctions", + title: "Separate Procedures and Functions", + run: toggleSeparateProceduresAndFunctions, + }), tree({ id: "db.collapseAll", title: "Collapse All", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 02b915f9f..599bb6e70 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,5 @@ { + "Separate Procedures and Functions": "Separate Procedures and Functions", "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.", "Share JDT LS across builds": "Share JDT LS across builds", "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.": "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 069609a36..4c9105419 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Separate Procedures and Functions": "分别显示过程和函数", "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将 Java 语言服务器的运行位置设为本机后再使用。", "Share JDT LS across builds": "在构建间共享 JDT LS", "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将工作区成员作为项目打开,再修改其此项设置。",