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 <[email protected]>
Цей коміт міститься в:
Budi Santosoтаboenkkk authored and GitHub committed 2026-11-09 18:05:59 +08:00
1 джерело f3f9fdb1ad
коміт 8ddf979cfe
12 files changed
+224 -26

No files matched your search

+1 -1
Переглянути файл
@@ -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")]
+7 -2
Переглянути файл
@@ -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.
+1 -1
Переглянути файл
@@ -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[],
+108
Переглянути файл
@@ -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(<DatabaseView />);
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(<DatabaseView />);
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(<DatabaseView />);
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();
});
});
+45 -19
Переглянути файл
@@ -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<Folder, string> = {
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<T>(key: string, l: Loaded<T> | 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<TreeHandle>(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",
+7 -1
Переглянути файл
@@ -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({
+17
Переглянути файл
@@ -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");
});
});
+8 -2
Переглянути файл
@@ -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;
+22
Переглянути файл
@@ -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<string, string>;
/** The Database tree's quick object filter (empty: everything shows). */
treeFilter: string;
separateProceduresAndFunctions: boolean;
completion: Record<string, DbCompletion>;
results: ResultTab[];
/** Result tab id, or `OUTPUT_TAB`. */
@@ -189,6 +191,15 @@ function readConsoleSchemas(): Record<string, string> {
}
}
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<DatabaseState>(() => ({
sources: [],
loaded: false,
@@ -201,6 +212,7 @@ export const useDatabase = create<DatabaseState>(() => ({
consoles: readBindings(),
consoleSchemas: readConsoleSchemas(),
treeFilter: "",
separateProceduresAndFunctions: readSeparateProceduresAndFunctions(),
completion: {},
results: [],
activeResult: null,
@@ -218,6 +230,16 @@ export const useDatabase = create<DatabaseState>(() => ({
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}`;
+6
Переглянути файл
@@ -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",
+1
Переглянути файл
@@ -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.",
+1
Переглянути файл
@@ -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 仅适用于本机的普通项目窗口。请将工作区成员作为项目打开,再修改其此项设置。",