diff --git a/desktop/src/components/db-viewer/DbViewerScreen.test.tsx b/desktop/src/components/db-viewer/DbViewerScreen.test.tsx index 3a4ac6e..03ab12a 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.test.tsx @@ -281,8 +281,8 @@ describe("DbViewerScreen", () => { it("executes a non-destructive query when Run is clicked", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") - .mockResolvedValue(mockQueryResult as any); + .spyOn(commands, "executeQueryMulti") + .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any); render( { it("shows a destructive-query confirmation dialog and executes on confirm", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") + .spyOn(commands, "executeQueryMulti") .mockResolvedValue({ - columns: [], - rows: [], - total_rows: 0, - page: 1, - page_size: 50, + result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], + notices: [], + execution_time_ms: 42, } as any); render( { }); it("refreshes the schema tree after a DDL query runs", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue( - mockQueryResult as any, + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue( + { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any, ); const getSchemas = vi .spyOn(commands, "getSchemas") @@ -377,8 +375,8 @@ describe("DbViewerScreen", () => { }); it("does not refresh the schema tree after a SELECT", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue( - mockQueryResult as any, + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue( + { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any, ); const getSchemas = vi .spyOn(commands, "getSchemas") @@ -396,7 +394,7 @@ describe("DbViewerScreen", () => { ); fireEvent.change(textarea, { target: { value: "SELECT 1" } }); fireEvent.click(screen.getByRole("button", { name: /run query/i })); - await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled()); + await waitFor(() => expect(commands.executeQueryMulti).toHaveBeenCalled()); expect(getSchemas).not.toHaveBeenCalled(); }); @@ -423,8 +421,8 @@ describe("DbViewerScreen", () => { it("runs the current query when the Cmd+Enter action fires", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") - .mockResolvedValue(mockQueryResult as any); + .spyOn(commands, "executeQueryMulti") + .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any); render( { const pending = new Promise((r) => { resolveRun = r; }); - vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any); + vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any); render( { await waitFor(() => expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(), ); - resolveRun(mockQueryResult); + resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 }); await waitFor(() => expect(screen.queryByTestId("query-run-pulse")).toBeNull(), ); @@ -477,8 +475,8 @@ describe("DbViewerScreen", () => { it("collapses and re-expands the query results via the caret", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") - .mockResolvedValue(mockQueryResult as any); + .spyOn(commands, "executeQueryMulti") + .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any); render( { }); it("resizes the results panel with a drag handle, clamped to min/max", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue( - mockQueryResult as any, + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue( + { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any, ); render( { }); it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any); + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue({ result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], notices: [], execution_time_ms: 42 } as any); vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]); vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]); vi.spyOn(commands, "getTables").mockResolvedValue([] as any); diff --git a/desktop/src/components/db-viewer/DbViewerScreen.tsx b/desktop/src/components/db-viewer/DbViewerScreen.tsx index cd2a518..04c79aa 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.tsx @@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip"; import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar"; import { DbViewerToolbar } from "./DbViewerToolbar"; import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils"; -import { executeQuery, cancelQuery } from "../../lib/commands"; +import { executeQueryMulti, cancelQuery } from "../../lib/commands"; import { getCapabilities } from "../../lib/dbCapabilities"; const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor }))); @@ -19,6 +19,7 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail"; import { ObjectFormTab } from "./objects/ObjectFormTab"; import { TabBar } from "./TabBar"; import { VirtualDataGrid } from "../grid/VirtualDataGrid"; +import { ResultSetPanels } from "./ResultSetPanels"; import { RowDetailDrawer } from "../grid/RowDetailDrawer"; import { TableControls } from "./TableControls"; import { EditConnectionModal } from "./EditConnectionModal"; @@ -260,6 +261,7 @@ export function DbViewerScreen({ const setTabData = useDbViewerStore((s) => s.setTabData); const setTabError = useDbViewerStore((s) => s.setTabError); + const setTabMulti = useDbViewerStore((s) => s.setTabMulti); const setTabLoading = useDbViewerStore((s) => s.setTabLoading); const databases = useDbViewerStore((s) => s.databases); const currentDatabase = useDbViewerStore((s) => s.currentDatabase); @@ -311,8 +313,24 @@ export function DbViewerScreen({ if (!tab) return; setTabLoading(tabId, true); try { - const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize); - setTabData(tabId, result); + const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize); + const first = multi.result_sets[0] ?? null; + // Stamp the run's execution time onto the first set so the toolbar + // (TableControls reads activeTab.data.execution_time_ms) still shows it. + if (first) { + setTabData(tabId, { ...first, execution_time_ms: multi.execution_time_ms }); + } else { + // Lone DML/DDL run with no result set — keep the empty-state behavior. + setTabData(tabId, { + columns: [], + rows: [], + total_rows: 0, + page: tab.page, + page_size: tab.pageSize, + execution_time_ms: multi.execution_time_ms, + }); + } + setTabMulti(tabId, multi); useQueryStore.getState().invalidateHistory(connectionId); if (isSchemaModifyingQuery(sql)) { const st = useDbViewerStore.getState(); @@ -1017,6 +1035,48 @@ const onQueriesPanelResizeStart = useCallback( ) : {}; + // Shared VirtualDataGrid props for the query-tab results region. The + // multi-result panels reuse these (overriding rows/columns per set); + // single-set runs render the grid directly with today's props. + const gridPropsForQueryTab = { + connectionId, + schema: activeSchema, + table: activeTable, + rows: displayRows, + columns, + hiddenColumns, + selectedRows, + dbType: currentConnection?.db_type ?? "postgresql", + tabType: activeTab?.tabType ?? "table", + getLocator, + onStageEdit: readOnlyTable ? undefined : handleStageEdit, + onStageInsertCell: readOnlyTable ? undefined : handleStageInsertCell, + onOpenRowDetail: handleOpenRowDetail, + readOnly: readOnlyTable, + enumValues: editorOptions?.enums, + fkOptions: editorOptions?.fks, + fkPlaceholders: editorOptions?.fkPlaceholders, + stagedValues, + pendingKeys, + pendingInsertChangeIds, + onToggleRow: (rowIndex: number) => { + setSelectedRows((prev) => { + const next = new Set(prev); + if (next.has(rowIndex)) next.delete(rowIndex); + else next.add(rowIndex); + return next; + }); + }, + onToggleAll: () => { + setSelectedRows((prev) => { + if (prev.size === processedRows.length && processedRows.length > 0) { + return new Set(); + } + return new Set(processedRows.map((_, i) => i)); + }); + }, + }; + return (
@@ -1212,71 +1272,16 @@ const onQueriesPanelResizeStart = useCallback( /> )}
- { - setSelectedRows( - (prev) => { - const next = - new Set( - prev, - ); - if ( - next.has( - rowIndex, - ) - ) - next.delete( - rowIndex, - ); - else - next.add( - rowIndex, - ); - return next; - }, - ); - }} - onToggleAll={() => { - setSelectedRows( - (prev) => { - if ( - prev.size === - processedRows.length && - processedRows.length > - 0 - ) { - return new Set(); - } - return new Set( - processedRows.map( - (_, i) => - i, - ), - ); - }, - ); - }} - /> + {activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? ( + ( + + )} + /> + ) : ( + + )}
diff --git a/desktop/src/components/db-viewer/ResultSetPanels.test.tsx b/desktop/src/components/db-viewer/ResultSetPanels.test.tsx new file mode 100644 index 0000000..58243fb --- /dev/null +++ b/desktop/src/components/db-viewer/ResultSetPanels.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ResultSetPanels } from "./ResultSetPanels"; +import type { MultiQueryResult } from "../../lib/types"; + +const multi: MultiQueryResult = { + result_sets: [ + { columns: [{ name: "a", data_type: "int", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [[1], [2]], total_rows: 2, page: 1, page_size: 50 }, + { columns: [{ name: "b", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [], total_rows: 0, page: 1, page_size: 50 }, + ], + notices: [ + { statement_index: 1, kind: "dml", text: "UPDATE 3", affected: 3 }, + { statement_index: 3, kind: "error", text: "relation \"nope\" does not exist", affected: null }, + ], + execution_time_ms: 12, +}; + +describe("ResultSetPanels", () => { + it("renders one panel header per result set with row counts", () => { + render(); + expect(screen.getByText(/Result 1/)).toBeTruthy(); + expect(screen.getByText(/Result 2/)).toBeTruthy(); + expect(screen.getByText(/2 rows/)).toBeTruthy(); + }); + + it("renders notice cards for DML and errors", () => { + render(); + expect(screen.getByText(/UPDATE 3/)).toBeTruthy(); + expect(screen.getByText(/3 rows affected/)).toBeTruthy(); + expect(screen.getByText(/relation "nope" does not exist/)).toBeTruthy(); + }); + + it("delegates each set to the grid renderer with its index", () => { + const renderGrid = vi.fn(() =>
); + render(); + expect(renderGrid).toHaveBeenCalledTimes(2); + }); +}); diff --git a/desktop/src/components/db-viewer/ResultSetPanels.tsx b/desktop/src/components/db-viewer/ResultSetPanels.tsx new file mode 100644 index 0000000..fc2067f --- /dev/null +++ b/desktop/src/components/db-viewer/ResultSetPanels.tsx @@ -0,0 +1,42 @@ +import { AlertCircle, Info } from "lucide-react"; +import type { MultiQueryResult, QueryResult } from "../../lib/types"; + +export function ResultSetPanels({ + multi, + renderGrid, +}: { + multi: MultiQueryResult; + renderGrid: (set: QueryResult, index: number) => React.ReactNode; +}) { + return ( +
+ {multi.result_sets.map((set, i) => ( +
+
+ Result {i + 1} + — {set.total_rows} row{set.total_rows === 1 ? "" : "s"} +
+
+ {renderGrid(set, i)} +
+
+ ))} + {multi.notices.map((n, i) => ( +
+ {n.kind === "error" ? : } + Statement {n.statement_index + 1} + {n.text} + {n.affected !== null && n.affected !== undefined && ( + ({n.affected} rows affected) + )} +
+ ))} +
+ ); +} diff --git a/desktop/src/lib/commands.ts b/desktop/src/lib/commands.ts index d24d942..254ab99 100644 --- a/desktop/src/lib/commands.ts +++ b/desktop/src/lib/commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse } from "./types"; +import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types"; import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { ChangePayload } from "./changePayload"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; @@ -286,6 +286,15 @@ export async function executeQuery( return invoke("execute_query", { connectionId, query, page, pageSize }); } +export async function executeQueryMulti( + connectionId: string, + query: string, + page: number, + pageSize: number, +): Promise { + return invoke("execute_query_multi", { connectionId, query, page, pageSize }); +} + export async function getQueryHistory( connectionId: string | null, limit: number, diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index fee94ab..477fef9 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -189,6 +189,19 @@ export interface QueryResult { error?: string | null; } +export interface StatementNotice { + statement_index: number; + kind: "dml" | "ddl" | "error" | string; + text: string; + affected: number | null; +} + +export interface MultiQueryResult { + result_sets: QueryResult[]; + notices: StatementNotice[]; + execution_time_ms: number; +} + export type ChangeStatus = "pending" | "applied" | "error"; export type ChangeItemType = diff --git a/desktop/src/stores/dbViewerStore.ts b/desktop/src/stores/dbViewerStore.ts index 6ea9732..acfa360 100644 --- a/desktop/src/stores/dbViewerStore.ts +++ b/desktop/src/stores/dbViewerStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types"; +import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; import type { ObjectKind, DdlParams } from "../lib/objectCrud"; @@ -58,6 +58,7 @@ export interface ViewerTab { loading: boolean; error: string | null; data: QueryResult | null; + multi?: MultiQueryResult | null; columnFilter?: { column: string; value: string }; filterRules: FilterRule[]; sortRules: SortRule[]; @@ -146,6 +147,7 @@ interface DbViewerState { setPage: (tabId: string, page: number) => void; setPageSize: (tabId: string, pageSize: number) => void; setTabData: (tabId: string, data: QueryResult) => void; + setTabMulti: (tabId: string, multi: MultiQueryResult | null) => void; setTabLoading: (tabId: string, loading: boolean) => void; setTabError: (tabId: string, error: string) => void; setColumnFilter: (tabId: string, column: string, value: string) => void; @@ -449,10 +451,15 @@ export const useDbViewerStore = create((set, get) => ({ setTabData: (tabId, data) => set((state) => ({ tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, data, loading: false, error: null } : t, + t.id === tabId ? { ...t, data, multi: null, loading: false, error: null } : t, ), })), + setTabMulti: (tabId, multi) => + set((s) => ({ + tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, multi } : t)), + })), + setTabLoading: (tabId, loading) => set((state) => ({ tabs: state.tabs.map((t) =>