diff --git a/src/features/schema/ObjectDetails.tsx b/src/features/schema/ObjectDetails.tsx index f3d8cb4..5aff09b 100644 --- a/src/features/schema/ObjectDetails.tsx +++ b/src/features/schema/ObjectDetails.tsx @@ -6,16 +6,20 @@ import { localizeConnectionError } from "../../lib/errors"; import { type ColumnInfo, type ForeignKeyInfo, + type FunctionDefinition, type IndexInfo, type MatviewDefinition, + type ProcedureDefinition, type ViewInfo, + schemaGetFunctionDefinition, schemaGetMatviewDefinition, + schemaGetProcedureDefinition, schemaListColumns, schemaListForeignKeys, schemaListIndexes, schemaListViews, } from "../../lib/tauri"; -import { type NodeKey, useSchema } from "./store"; +import { type NodeKey, parseRoutineKey, useSchema } from "./store"; /** * Main-area panel for selected tree node (spec §5.4 / §7.4). @@ -53,7 +57,19 @@ interface ParsedMatview { name: string; } -type ParsedNode = ParsedTable | ParsedView | ParsedMatview | { kind: "placeholder" }; +interface ParsedRoutine { + kind: "function" | "procedure"; + schema: string; + name: string; + args: string; +} + +type ParsedNode = + | ParsedTable + | ParsedView + | ParsedMatview + | ParsedRoutine + | { kind: "placeholder" }; function parseNode(node: NodeKey | null): ParsedNode { if (node === null) return { kind: "placeholder" }; @@ -75,6 +91,10 @@ function parseNode(node: NodeKey | null): ParsedNode { if (slash === -1) return { kind: "placeholder" }; return { kind: "matview", schema: rest.slice(0, slash), name: rest.slice(slash + 1) }; } + const routine = parseRoutineKey(node); + if (routine) { + return routine; + } return { kind: "placeholder" }; } @@ -139,6 +159,19 @@ export function ObjectDetails({ node }: { node: NodeKey | null }): JSX.Element { ); } + if (parsed.kind === "function" || parsed.kind === "procedure") { + return ( + + ); + } + return ( >(initialFetch); + + const load = useCallback(() => { + setState({ loading: true, data: null, error: null }); + const fetchDef = + kind === "function" + ? schemaGetFunctionDefinition(connId, schema, name, args) + : schemaGetProcedureDefinition(connId, schema, name, args); + fetchDef + .then((def) => { + setState({ loading: false, data: def, error: null }); + }) + .catch((err: unknown) => { + const message = localizeConnectionError(err, t); + setState({ loading: false, data: null, error: message }); + toast.error(message); + }); + }, [kind, connId, schema, name, args, t, toast]); + + useEffect(() => { + load(); + }, [load]); + + return ( +
+
+ {}} controls="panel-definition" id="tab-definition"> + {t("object_details.section.definition")} + +
+
+
+ {state.loading ? : null} + {state.error ? : null} + {state.data ? ( +
+              {`-- ${state.data.name}(${args})  ·  language: ${state.data.language}\n\n${state.data.body}`}
+            
+ ) : null} +
+
+
+ ); +} + function TabButton({ active, onClick, diff --git a/src/features/schema/SchemaTree.tsx b/src/features/schema/SchemaTree.tsx index fa1a56c..304772f 100644 --- a/src/features/schema/SchemaTree.tsx +++ b/src/features/schema/SchemaTree.tsx @@ -6,7 +6,9 @@ import { Database, Eye, Folder, + FunctionSquare, Layers, + SquareFunction, Table2, } from "lucide-react"; import { @@ -95,7 +97,9 @@ function buildTreeNodes( const displayName = child.kind === "tables-group" || child.kind === "views-group" || - child.kind === "matviews-group" + child.kind === "matviews-group" || + child.kind === "functions-group" || + child.kind === "procedures-group" ? translate(child.name) : child.name; return { @@ -120,12 +124,20 @@ function iconForKind(kind: NodeKind): JSX.Element | null { return ; case "matviews-group": return ; + case "functions-group": + return ; + case "procedures-group": + return ; case "table": return ; case "view": return ; case "matview": return ; + case "function": + return ; + case "procedure": + return ; case "column": return ; } @@ -219,7 +231,13 @@ function Row({ // column rows: click is a no-op; only the JSONB chevron toggles the panel return; } - if (kind === "table" || kind === "view" || kind === "matview") { + if ( + kind === "table" || + kind === "view" || + kind === "matview" || + kind === "function" || + kind === "procedure" + ) { // Tables and views: select the node AND toggle column expansion. // Only call onToggle (which triggers loadChildren) if the node is a // non-leaf in the tree — i.e., the cache already knows it has children. @@ -254,7 +272,12 @@ function Row({ // "isLeaf" is used only to apply the "ident" CSS class — originally tables/views // were leaves; they still get the same class treatment. - const isLeaf = kind === "table" || kind === "view" || kind === "matview"; + const isLeaf = + kind === "table" || + kind === "view" || + kind === "matview" || + kind === "function" || + kind === "procedure"; const isColumn = kind === "column"; // S28 — hypertable badge: looks up isHypertable in useTimescale's per-conn map. @@ -516,7 +539,13 @@ export function SchemaTree({ onOpenBuilder, onOpenSuggester }: SchemaTreeProps = const handleSelect = useCallback( (id: NodeKey, kind: NodeKind) => { - if (kind === "table" || kind === "view" || kind === "matview") { + if ( + kind === "table" || + kind === "view" || + kind === "matview" || + kind === "function" || + kind === "procedure" + ) { selectNode(id); // also dispatch an editor object tab so the user gets a // pinned ObjectDetails view in the main area without losing other diff --git a/src/features/schema/store.test.ts b/src/features/schema/store.test.ts index ff1f966..5bc3200 100644 --- a/src/features/schema/store.test.ts +++ b/src/features/schema/store.test.ts @@ -1,5 +1,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { ConnectInfo, MatviewSummary, SchemaInfo, TableInfo, ViewInfo } from "../../lib/tauri"; +import type { + ConnectInfo, + FunctionSummary, + MatviewSummary, + ProcedureSummary, + SchemaInfo, + TableInfo, + ViewInfo, +} from "../../lib/tauri"; const connectionConnectMock = vi.fn(); const connectionDisconnectMock = vi.fn(); @@ -7,6 +15,8 @@ const schemaListSchemasMock = vi.fn(); const schemaListTablesMock = vi.fn(); const schemaListViewsMock = vi.fn(); const schemaListMatviewsMock = vi.fn(); +const schemaListFunctionsMock = vi.fn(); +const schemaListProceduresMock = vi.fn(); vi.mock("../../lib/tauri", async () => { const actual = await vi.importActual("../../lib/tauri"); @@ -18,10 +28,14 @@ vi.mock("../../lib/tauri", async () => { schemaListTables: (connId: string, schema: string) => schemaListTablesMock(connId, schema), schemaListViews: (connId: string, schema: string) => schemaListViewsMock(connId, schema), schemaListMatviews: (connId: string, schema: string) => schemaListMatviewsMock(connId, schema), + schemaListFunctions: (connId: string, schema: string, triggerOnly: boolean) => + schemaListFunctionsMock(connId, schema, triggerOnly), + schemaListProcedures: (connId: string, schema: string) => + schemaListProceduresMock(connId, schema), }; }); -import { __testing, useSchema } from "./store"; +import { __testing, parseRoutineKey, routineKey, useSchema } from "./store"; const initial = useSchema.getState(); @@ -53,6 +67,16 @@ const dailyStatsMatview: MatviewSummary = { populated: true, comment: null, }; +const addFunction: FunctionSummary = { + name: "add", + args: "a integer, b integer", + returnKind: "scalar", + returnType: "integer", +}; +const doThingProcedure: ProcedureSummary = { + name: "do_thing", + args: "", +}; beforeEach(() => { connectionConnectMock.mockReset(); @@ -61,6 +85,8 @@ beforeEach(() => { schemaListTablesMock.mockReset(); schemaListViewsMock.mockReset(); schemaListMatviewsMock.mockReset(); + schemaListFunctionsMock.mockReset(); + schemaListProceduresMock.mockReset(); reset(); }); @@ -173,7 +199,7 @@ describe("useSchema loadChildren", () => { expect(useSchema.getState().cache.get("schemas")).toEqual(children); }); - test('"schema:NAME" → synthetic tables-group + views-group + matviews-group, no backend call', async () => { + test('"schema:NAME" → synthetic tables/views/matviews/functions/procedures groups, no backend call', async () => { const children = await useSchema.getState().loadChildren("schema:public"); expect(children).toEqual([ @@ -195,10 +221,24 @@ describe("useSchema loadChildren", () => { kind: "matviews-group", hasChildren: true, }, + { + id: "schema:public/functions", + name: "schema.tree.functions_group", + kind: "functions-group", + hasChildren: true, + }, + { + id: "schema:public/procedures", + name: "schema.tree.procedures_group", + kind: "procedures-group", + hasChildren: true, + }, ]); expect(schemaListTablesMock).not.toHaveBeenCalled(); expect(schemaListViewsMock).not.toHaveBeenCalled(); expect(schemaListMatviewsMock).not.toHaveBeenCalled(); + expect(schemaListFunctionsMock).not.toHaveBeenCalled(); + expect(schemaListProceduresMock).not.toHaveBeenCalled(); }); test('"schema:NAME/tables" → maps to table leaf children', async () => { @@ -245,6 +285,52 @@ describe("useSchema loadChildren", () => { expect(schemaListMatviewsMock).toHaveBeenCalledWith("conn-1", "public"); }); + test('"schema:NAME/functions" → maps to function leaf children (args-encoded key)', async () => { + schemaListFunctionsMock.mockResolvedValueOnce([addFunction]); + + const children = await useSchema.getState().loadChildren("schema:public/functions"); + + expect(children).toEqual([ + { + // name + args encoded into the key so overloads never collide + id: `function:public/add#${encodeURIComponent("a integer, b integer")}`, + name: "add(a integer, b integer)", + kind: "function", + hasChildren: false, + }, + ]); + // triggerOnly=false → list all functions, not just trigger-returning ones. + expect(schemaListFunctionsMock).toHaveBeenCalledWith("conn-1", "public", false); + }); + + test("function overloads produce distinct node keys", async () => { + schemaListFunctionsMock.mockResolvedValueOnce([ + { name: "f", args: "a integer", returnKind: "scalar", returnType: "integer" }, + { name: "f", args: "a text", returnKind: "scalar", returnType: "text" }, + ]); + + const children = await useSchema.getState().loadChildren("schema:public/functions"); + + expect(children).toHaveLength(2); + expect(children[0].id).not.toBe(children[1].id); + }); + + test('"schema:NAME/procedures" → maps to procedure leaf children', async () => { + schemaListProceduresMock.mockResolvedValueOnce([doThingProcedure]); + + const children = await useSchema.getState().loadChildren("schema:public/procedures"); + + expect(children).toEqual([ + { + id: "procedure:public/do_thing#", + name: "do_thing", + kind: "procedure", + hasChildren: false, + }, + ]); + expect(schemaListProceduresMock).toHaveBeenCalledWith("conn-1", "public"); + }); + test("returns cached value without re-fetching", async () => { schemaListSchemasMock.mockResolvedValueOnce([publicSchema]); await useSchema.getState().loadChildren("schemas"); @@ -365,6 +451,32 @@ describe("useSchema refreshAll", () => { }); }); +describe("routine key codec", () => { + test("round-trips schema/name/args incl commas, spaces, brackets", () => { + const key = routineKey("function", "public", "add", "a integer, b text[]"); + expect(parseRoutineKey(key)).toEqual({ + kind: "function", + schema: "public", + name: "add", + args: "a integer, b text[]", + }); + }); + + test("round-trips empty args", () => { + expect(parseRoutineKey(routineKey("procedure", "public", "do_thing", ""))).toEqual({ + kind: "procedure", + schema: "public", + name: "do_thing", + args: "", + }); + }); + + test("returns null for non-routine keys", () => { + expect(parseRoutineKey("table:public/users")).toBeNull(); + expect(parseRoutineKey("matview:public/m")).toBeNull(); + }); +}); + describe("useSchema setters", () => { test("selectNode + setFilter update state", () => { useSchema.getState().selectNode("table:public/users"); diff --git a/src/features/schema/store.ts b/src/features/schema/store.ts index 0708e21..bded27f 100644 --- a/src/features/schema/store.ts +++ b/src/features/schema/store.ts @@ -7,10 +7,9 @@ * user expanding/collapsing the same node rapidly never triggers duplicate * Tauri command round-trips. * - * Synthetic group nodes ("schema:NAME") are expanded into three virtual children - * ("schema:NAME/tables", "schema:NAME/views", "schema:NAME/matviews") without - * hitting the backend; the `name` field carries an i18n key so render-site - * components can localize. + * Synthetic group nodes ("schema:NAME") are expanded into five virtual children + * (tables, views, matviews, functions, procedures) without hitting the backend; + * the `name` field carries an i18n key so render-site components can localize. */ import { create } from "zustand"; @@ -20,7 +19,9 @@ import { connectionConnect, connectionDisconnect, schemaListColumns, + schemaListFunctions, schemaListMatviews, + schemaListProcedures, schemaListSchemas, schemaListTables, schemaListViews, @@ -49,9 +50,13 @@ export type NodeKind = | "tables-group" | "views-group" | "matviews-group" + | "functions-group" + | "procedures-group" | "table" | "view" | "matview" + | "function" + | "procedure" | "column"; export type NodeKey = string; @@ -127,6 +132,8 @@ function parseKey( | { kind: "tables-group"; schema: string } | { kind: "views-group"; schema: string } | { kind: "matviews-group"; schema: string } + | { kind: "functions-group"; schema: string } + | { kind: "procedures-group"; schema: string } | { kind: "table"; schema: string; table: string } | { kind: "unknown" } { if (key === "schemas") return { kind: "schemas" }; @@ -141,6 +148,12 @@ function parseKey( if (rest.endsWith("/matviews")) { return { kind: "matviews-group", schema: rest.slice(0, -"/matviews".length) }; } + if (rest.endsWith("/functions")) { + return { kind: "functions-group", schema: rest.slice(0, -"/functions".length) }; + } + if (rest.endsWith("/procedures")) { + return { kind: "procedures-group", schema: rest.slice(0, -"/procedures".length) }; + } return { kind: "schema", schema: rest }; } if (key.startsWith("table:")) { @@ -153,6 +166,46 @@ function parseKey( return { kind: "unknown" }; } +/** + * Function/procedure leaf NodeKeys must encode the argument signature: Postgres + * routines can be overloaded (same name, different args), and the definition + * fetch needs the exact args to disambiguate. Format + * `function:/#` (args percent-encoded so it can't + * collide with the `/` schema delimiter or the `#` args delimiter). Shared so + * the store (key builder) and ObjectDetails (decoder) round-trip identically. + */ +export function routineKey( + kind: "function" | "procedure", + schema: string, + name: string, + args: string, +): NodeKey { + return `${kind}:${schema}/${name}#${encodeURIComponent(args)}`; +} + +export function parseRoutineKey( + key: NodeKey, +): { kind: "function" | "procedure"; schema: string; name: string; args: string } | null { + const colon = key.indexOf(":"); + if (colon === -1) return null; + const prefix = key.slice(0, colon); + if (prefix !== "function" && prefix !== "procedure") return null; + const rest = key.slice(colon + 1); + const slash = rest.indexOf("/"); + if (slash === -1) return null; + const nameAndArgs = rest.slice(slash + 1); + // encodeURIComponent escapes '#', so the last '#' always separates the (raw) + // name from the encoded args, even if the name itself contains a '#'. + const hash = nameAndArgs.lastIndexOf("#"); + if (hash === -1) return null; + return { + kind: prefix, + schema: rest.slice(0, slash), + name: nameAndArgs.slice(0, hash), + args: decodeURIComponent(nameAndArgs.slice(hash + 1)), + }; +} + async function fetchChildren(parent: NodeKey, connId: string): Promise { const parsed = parseKey(parent); switch (parsed.kind) { @@ -187,6 +240,18 @@ async function fetchChildren(parent: NodeKey, connId: string): Promise ({ + id: routineKey("function", parsed.schema, f.name, f.args), + // Show the signature so overloads (same name, different args) are + // distinguishable in the tree. + name: f.args === "" ? f.name : `${f.name}(${f.args})`, + kind: "function" as const, + hasChildren: false, + })); + } + case "procedures-group": { + const list = await schemaListProcedures(connId, parsed.schema); + return list.map((p) => ({ + id: routineKey("procedure", parsed.schema, p.name, p.args), + name: p.args === "" ? p.name : `${p.name}(${p.args})`, + kind: "procedure" as const, + hasChildren: false, + })); + } case "table": { const list = await schemaListColumns(connId, parsed.schema, parsed.table); return list.map((c) => ({ @@ -413,11 +499,15 @@ export const useSchema = create((set, get) => ({ const tablesKey = `${schema.id}/tables`; const viewsKey = `${schema.id}/views`; const matviewsKey = `${schema.id}/matviews`; + const functionsKey = `${schema.id}/functions`; + const proceduresKey = `${schema.id}/procedures`; return [ get().loadChildren(schema.id), get().loadChildren(tablesKey), get().loadChildren(viewsKey), get().loadChildren(matviewsKey), + get().loadChildren(functionsKey), + get().loadChildren(proceduresKey), ]; }), ); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6479e25..bf8e982 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -179,6 +179,8 @@ "tables_group": "Tables", "views_group": "Views", "matviews_group": "Materialized views", + "functions_group": "Functions", + "procedures_group": "Procedures", "expand": "Expand", "collapse": "Collapse", "copy_name": "Copy name", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 21fbac7..ad33030 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -179,6 +179,8 @@ "tables_group": "Таблицы", "views_group": "Представления", "matviews_group": "Материализованные представления", + "functions_group": "Функции", + "procedures_group": "Процедуры", "expand": "Раскрыть", "collapse": "Свернуть", "copy_name": "Копировать имя",