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 仅适用于本机的普通项目窗口。请将工作区成员作为项目打开,再修改其此项设置。",