дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
1 джерело
f3f9fdb1ad
коміт
8ddf979cfe
12 files changed
+224
-26
No files matched your search
@@ -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")]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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,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,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 仅适用于本机的普通项目窗口。请将工作区成员作为项目打开,再修改其此项设置。",
|
||||
|
||||
Reference in new issue
Заблокувати користувача