diff --git a/README.md b/README.md index c36e3cb1..9c64e6ca 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ And nothing is held back. Single sign-on, ER diagrams, the AI features and the N - **Smart Autocomplete**: Schema-aware suggestions for tables, columns, and SQL keywords. - **Command Palette**: Quick access to tables, connections, saved queries, and actions with `Cmd/Ctrl+K`. - **Multi-Tab Workspace**: Handle parallel tasks with independent execution states. +- **Table Counts**: Explorer badges use compact K/M/B/T notation. For SQL tables, Select Table Count opens an editable `SELECT COUNT(*)` in a new tab; add filters and run it when ready. The existing badge remains the provider's reported count, which may be an estimate. - **Saved Query Backups**: Export the complete saved-query library as JSON. Import validates the file, preserves query metadata and merges new entries, reporting duplicate IDs while keeping existing queries intact. - **Duplicate Connections**: Open an independent `(copy)` of an editable saved connection in the connection editor, adjust its settings and save. Cancelling leaves the saved connections unchanged; administrator-managed connections cannot be duplicated. - **Visual EXPLAIN**: Graphical execution plans to identify performance bottlenecks. diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index e7b7db90..7275a445 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -515,6 +515,7 @@ export default function Studio() { onAddConnection={() => setIsConnectionModalOpen(true)} onTableClick={onTableClick} onGenerateSelect={tabMgr.handleGenerateSelect} + onGenerateCount={tabMgr.handleGenerateCount} onCreateTableClick={() => setIsCreateTableModalOpen(true)} onShowDiagram={() => setShowDiagram(true)} isAdmin={isAdmin} @@ -648,6 +649,10 @@ export default function Studio() { tabMgr.handleGenerateSelect(tableName); setActiveMobileTab("editor"); }} + onGenerateCount={(tableName) => { + tabMgr.handleGenerateCount(tableName); + setActiveMobileTab("editor"); + }} onCreateTableClick={() => setIsCreateTableModalOpen(true)} isAdmin={isAdmin} onOpenMaintenance={openMaintenance} diff --git a/src/components/schema-explorer/SchemaExplorer.tsx b/src/components/schema-explorer/SchemaExplorer.tsx index f1777658..0ee69d84 100644 --- a/src/components/schema-explorer/SchemaExplorer.tsx +++ b/src/components/schema-explorer/SchemaExplorer.tsx @@ -20,6 +20,7 @@ interface SchemaExplorerProps { schemaError?: string | null; onTableClick?: (tableName: string) => void; onGenerateSelect?: (tableName: string) => void; + onGenerateCount?: (tableName: string) => void; onCreateTableClick?: () => void; isAdmin?: boolean; onOpenMaintenance?: (tab?: "global" | "tables" | "sessions", table?: string) => void; @@ -36,6 +37,7 @@ export function SchemaExplorer({ schemaError = null, onTableClick, onGenerateSelect, + onGenerateCount, onCreateTableClick, isAdmin = false, onOpenMaintenance, @@ -195,6 +197,7 @@ export function SchemaExplorer({ isAdmin={isAdmin} onTableClick={onTableClick} onGenerateSelect={onGenerateSelect} + onGenerateCount={onGenerateCount} onProfileTable={onProfileTable} onGenerateCode={onGenerateCode} onGenerateTestData={onGenerateTestData} diff --git a/src/components/schema-explorer/TableItem.tsx b/src/components/schema-explorer/TableItem.tsx index 73a4da6b..7e8b0051 100644 --- a/src/components/schema-explorer/TableItem.tsx +++ b/src/components/schema-explorer/TableItem.tsx @@ -36,6 +36,11 @@ import { toast } from "sonner"; import { writeToClipboard } from "@/components/copy-button"; import { ColumnList } from "./ColumnList"; +const rowCountFormat = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); + interface TableItemProps { table: TableSchema; isExpanded: boolean; @@ -47,6 +52,7 @@ interface TableItemProps { isAdmin: boolean; onTableClick?: (tableName: string) => void; onGenerateSelect?: (tableName: string) => void; + onGenerateCount?: (tableName: string) => void; onProfileTable?: (tableName: string) => void; onGenerateCode?: (tableName: string) => void; onGenerateTestData?: (tableName: string) => void; @@ -55,7 +61,13 @@ interface TableItemProps { type TableItemCallbacks = Pick< TableItemProps, - "onTableClick" | "onGenerateSelect" | "onProfileTable" | "onGenerateCode" | "onGenerateTestData" | "onOpenMaintenance" + | "onTableClick" + | "onGenerateSelect" + | "onGenerateCount" + | "onProfileTable" + | "onGenerateCode" + | "onGenerateTestData" + | "onOpenMaintenance" >; /** @@ -115,6 +127,12 @@ function renderMenuItems({ {labels?.generateAction || "Generate Query"} + {rowsAreAddressable && capabilities?.queryLanguage === "sql" && callbacks.onGenerateCount && ( + callbacks.onGenerateCount?.(table.name)}> + + Select Table Count + + )} copyToClipboard(table.name, `${labels?.entityName || "Table"} name`)}> {"Copy Name"} @@ -198,6 +216,7 @@ export const TableItem = React.memo(function TableItem({ isAdmin, onTableClick, onGenerateSelect, + onGenerateCount, onProfileTable, onGenerateCode, onGenerateTestData, @@ -217,6 +236,7 @@ export const TableItem = React.memo(function TableItem({ const callbacks = { onTableClick, onGenerateSelect, + onGenerateCount, onProfileTable, onGenerateCode, onGenerateTestData, @@ -263,7 +283,7 @@ export const TableItem = React.memo(function TableItem({
{table.rowCount !== undefined && ( - {table.rowCount >= 1000 ? `${(table.rowCount / 1000).toFixed(1)}k` : table.rowCount} + {rowCountFormat.format(table.rowCount)} )} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 1e7c9304..4fe6322e 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -25,6 +25,7 @@ interface SidebarProps { onAddConnection: () => void; onTableClick?: (tableName: string) => void; onGenerateSelect?: (tableName: string) => void; + onGenerateCount?: (tableName: string) => void; onCreateTableClick?: () => void; onShowDiagram?: () => void; isAdmin?: boolean; @@ -49,6 +50,7 @@ export function Sidebar({ onAddConnection, onTableClick, onGenerateSelect, + onGenerateCount, onCreateTableClick, onShowDiagram, isAdmin = false, @@ -110,6 +112,7 @@ export function Sidebar({ schemaError={schemaError} onTableClick={onTableClick} onGenerateSelect={onGenerateSelect} + onGenerateCount={onGenerateCount} onCreateTableClick={onCreateTableClick} isAdmin={isAdmin} onOpenMaintenance={onOpenMaintenance} diff --git a/src/hooks/use-tab-manager.ts b/src/hooks/use-tab-manager.ts index 440fc16e..66738120 100644 --- a/src/hooks/use-tab-manager.ts +++ b/src/hooks/use-tab-manager.ts @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import type { DatabaseConnection, TableSchema, QueryTab } from "@/lib/types"; import type { ProviderMetadata } from "@/hooks/use-provider-metadata"; -import { generateTableQuery, generateSelectQuery } from "@/lib/query-generators"; +import { generateTableQuery, generateSelectQuery, generateCountQuery } from "@/lib/query-generators"; import { resolveTabType } from "@/lib/editor/tab-language"; import { logger } from "@/lib/logger"; import { newLocalId } from "@/lib/ids"; @@ -239,6 +239,27 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks [metadata, schema], ); + const handleGenerateCount = useCallback( + (tableName: string) => { + const query = metadata ? generateCountQuery(tableName, metadata.capabilities) : null; + if (!query) return; + const id = newLocalId(); + setTabs((prev) => [ + ...prev, + { + id, + name: `Count: ${tableName}`, + query, + result: null, + isExecuting: false, + type: "sql", + }, + ]); + setActiveTabId(id); + }, + [metadata], + ); + return { tabs, setTabs, @@ -255,5 +276,6 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks updateTabById, handleTableClick, handleGenerateSelect, + handleGenerateCount, }; } diff --git a/src/lib/query-generators.ts b/src/lib/query-generators.ts index b9628995..690a432d 100644 --- a/src/lib/query-generators.ts +++ b/src/lib/query-generators.ts @@ -319,8 +319,8 @@ function redisScan(base: string): string { * The terminator a generated statement ends with: `;` everywhere, and nothing on a * product whose grammar has none. * - * Only the two shapes a user reaches by CLICKING are bounded here - the schema tree's - * "Select Top N" and "Generate Query" - because those are the statements this file + * The shapes a user reaches by CLICKING are bounded here - the schema tree's + * "Select Top N", "Generate Query" and "Select Table Count" - because those are the statements this file * writes on the user's behalf. The dialect-specific returns above keep their own * literal `;`: each of those engines accepts one, and this is the fallthrough every * other SQL engine shares, which is where the two search products land. See @@ -330,6 +330,11 @@ function terminator(capabilities: ProviderCapabilities): string { return capabilities.statementTerminator === "none" ? "" : ";"; } +export function generateCountQuery(tableName: string, capabilities: ProviderCapabilities): string | null { + if (capabilities.queryLanguage !== "sql" || capabilities.tablesAreDerivedGroupings) return null; + return `SELECT COUNT(*) FROM ${quoteQualifiedName(tableName, capabilities)}${terminator(capabilities)}`; +} + /** * The one refusal both LibreDB generators give for a name they cannot address: * a `#` note and no command line. A schema-tree node name is a real key name, diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index 4d308a27..5ea9952a 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -323,6 +323,7 @@ export function StudioWorkspace({ onAddConnection={noop} onTableClick={onTableClick} onGenerateSelect={tabMgr.handleGenerateSelect} + onGenerateCount={tabMgr.handleGenerateCount} onCreateTableClick={undefined} onShowDiagram={features.schemaDiagram ? () => setShowDiagram(true) : undefined} isAdmin={false} diff --git a/tests/components/Studio.test.tsx b/tests/components/Studio.test.tsx index 581c6f4b..6aa9454a 100644 --- a/tests/components/Studio.test.tsx +++ b/tests/components/Studio.test.tsx @@ -45,6 +45,7 @@ const mockUpdateCurrentTab = mock(() => {}); const mockUpdateTabById = mock(() => {}); const mockHandleTableClick = mock(() => {}); const mockHandleGenerateSelect = mock(() => {}); +const mockHandleGenerateCount = mock(() => {}); // Transaction Control const mockResetTransactionState = mock(() => {}); const mockSetPlaygroundMode = mock(() => {}); @@ -154,6 +155,7 @@ mock.module("@/hooks/use-tab-manager", () => ({ updateTabById: mockUpdateTabById, handleTableClick: mockHandleTableClick, handleGenerateSelect: mockHandleGenerateSelect, + handleGenerateCount: mockHandleGenerateCount, ...tabMgrOverride, })), })); @@ -534,6 +536,7 @@ describe("Studio", () => { mockUpdateTabById.mockClear(); mockHandleTableClick.mockClear(); mockHandleGenerateSelect.mockClear(); + mockHandleGenerateCount.mockClear(); mockResetTransactionState.mockClear(); mockHandleTransaction.mockClear(); mockSetPlaygroundMode.mockClear(); @@ -1663,6 +1666,17 @@ describe("Studio", () => { expect(queryByTestId("schema-explorer")).toBeNull(); }); + test("desktop and mobile schema surfaces generate a count without executing", () => { + connMgrOverride = { activeConnection: pgConn }; + const { queryByTestId } = render(); + expect(capturedSidebarProps.onGenerateCount).toBe(mockHandleGenerateCount); + act(() => (capturedMobileNavProps.onTabChange as (tab: string) => void)("schema")); + act(() => (capturedSchemaExplorerProps.onGenerateCount as (name: string) => void)("users")); + expect(mockHandleGenerateCount).toHaveBeenCalledWith("users"); + expect(mockExecuteQuery).not.toHaveBeenCalled(); + expect(queryByTestId("schema-explorer") === null).toBe(true); + }); + test("mobile schema tab table tool callbacks open modals and maintenance", () => { connMgrOverride = { activeConnection: pgConn }; const { queryByTestId } = render(); diff --git a/tests/components/StudioWorkspace.test.tsx b/tests/components/StudioWorkspace.test.tsx index f97f61c6..98095eb1 100644 --- a/tests/components/StudioWorkspace.test.tsx +++ b/tests/components/StudioWorkspace.test.tsx @@ -36,6 +36,7 @@ const mockUpdateCurrentTab = mock(() => {}); const mockUpdateTabById = mock(() => {}); const mockHandleTableClick = mock(() => {}); const mockHandleGenerateSelect = mock(() => {}); +const mockHandleGenerateCount = mock(() => {}); // Query adapter const mockExecuteQuery = mock(() => {}); const mockForceExecuteQuery = mock(() => {}); @@ -137,6 +138,7 @@ mock.module("@/hooks/use-tab-manager", () => ({ updateTabById: mockUpdateTabById, handleTableClick: mockHandleTableClick, handleGenerateSelect: mockHandleGenerateSelect, + handleGenerateCount: mockHandleGenerateCount, ...tabMgrOverride, }; }), @@ -372,6 +374,7 @@ describe("StudioWorkspace", () => { mockUpdateTabById.mockClear(); mockHandleTableClick.mockClear(); mockHandleGenerateSelect.mockClear(); + mockHandleGenerateCount.mockClear(); mockExecuteQuery.mockClear(); mockForceExecuteQuery.mockClear(); mockCancelQuery.mockClear(); @@ -712,6 +715,7 @@ describe("StudioWorkspace", () => { renderWorkspace(); expect(capturedSidebarProps.onSelectConnection).toBe(mockSetActiveConnection); expect(capturedSidebarProps.onGenerateSelect).toBe(mockHandleGenerateSelect); + expect(capturedSidebarProps.onGenerateCount).toBe(mockHandleGenerateCount); expect(capturedSidebarProps.isAdmin).toBe(false); // noop callbacks do not throw act(() => (capturedSidebarProps.onDeleteConnection as () => void)()); diff --git a/tests/components/schema-explorer/SchemaExplorer.test.tsx b/tests/components/schema-explorer/SchemaExplorer.test.tsx index ad0ef33e..c674ae71 100644 --- a/tests/components/schema-explorer/SchemaExplorer.test.tsx +++ b/tests/components/schema-explorer/SchemaExplorer.test.tsx @@ -9,16 +9,20 @@ import { setupFramerMotionMock } from "../../helpers/mock-monaco"; setupFramerMotionMock(); // Mock the child TableItem component to simplify testing +let capturedGenerateCount: unknown; mock.module("@/components/schema-explorer/TableItem", () => ({ TableItem: ({ table, isExpanded, onToggle, + onGenerateCount, }: { table: { name: string }; isExpanded: boolean; onToggle: () => void; + onGenerateCount?: (name: string) => void; }) => { + capturedGenerateCount = onGenerateCount; // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require("react"); return React.createElement( @@ -94,6 +98,11 @@ function createDefaultProps(overrides: Partial } describe("SchemaExplorer", () => { + test("forwards the count action to table rows", () => { + const onGenerateCount = mock(() => {}); + render(); + expect(capturedGenerateCount).toBe(onGenerateCount); + }); afterEach(() => { cleanup(); }); diff --git a/tests/components/schema-explorer/TableItem.test.tsx b/tests/components/schema-explorer/TableItem.test.tsx index 7b372e4c..92e08bb3 100644 --- a/tests/components/schema-explorer/TableItem.test.tsx +++ b/tests/components/schema-explorer/TableItem.test.tsx @@ -193,11 +193,72 @@ describe("TableItem", () => { expect(queryByText("users")).not.toBeNull(); }); - test("renders row count formatted as K for >= 1000", () => { + test.each([ + [0, "0"], + [999, "999"], + [1500, "1.5K"], + [999999, "1M"], + [2500000, "2.5M"], + [1200000000, "1.2B"], + [3000000000000, "3T"], + ])("renders row count %i as %s", (rowCount, label) => { const { queryByText } = render( - {})} isAdmin={false} />, + {})} isAdmin={false} />, + ); + expect(queryByText(label) !== null).toBe(true); + }); + + test.each(["dropdown", "context-menu"])("Select Table Count uses the count handler in %s", (menu) => { + const onGenerateCount = mock((_name: string) => {}); + const onTableClick = mock((_name: string) => {}); + const onToggle = mock(() => {}); + const { getByTestId } = render( + , + ); + fireEvent.click(within(getByTestId(menu)).getByText("Select Table Count")); + expect(onGenerateCount).toHaveBeenCalledTimes(1); + expect(onGenerateCount).toHaveBeenCalledWith("users"); + expect(onTableClick).not.toHaveBeenCalled(); + expect(onToggle).not.toHaveBeenCalled(); + }); + + test.each([ + undefined, + caps({ queryLanguage: "json" }), + caps({ queryLanguage: "sql", tablesAreDerivedGroupings: true }), + ])("hides Select Table Count without addressable SQL tables", (capabilities) => { + const { queryAllByText } = render( + {})} + isAdmin={false} + capabilities={capabilities} + onGenerateCount={mock(() => {})} + />, + ); + expect(queryAllByText("Select Table Count")).toHaveLength(0); + }); + + test("hides Select Table Count when a host supplies no count handler", () => { + const { queryAllByText } = render( + {})} + isAdmin={false} + capabilities={caps({ queryLanguage: "sql" })} + />, ); - expect(queryByText("1.5k")).not.toBeNull(); + expect(queryAllByText("Select Table Count")).toHaveLength(0); }); test("renders raw row count for < 1000", () => { diff --git a/tests/components/sidebar/Sidebar.test.tsx b/tests/components/sidebar/Sidebar.test.tsx index d950a3ac..71953f63 100644 --- a/tests/components/sidebar/Sidebar.test.tsx +++ b/tests/components/sidebar/Sidebar.test.tsx @@ -25,8 +25,10 @@ mock.module("@/components/sidebar/ConnectionsList", () => ({ }, })); +let capturedGenerateCount: unknown; mock.module("@/components/schema-explorer", () => ({ SchemaExplorer: (props: Record) => { + capturedGenerateCount = props.onGenerateCount; // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require("react"); const schema = props.schema as Array | undefined; @@ -117,6 +119,11 @@ function createDefaultProps(overrides: Record = {}) { } describe("Sidebar", () => { + test("forwards the count action to the explorer", () => { + const onGenerateCount = mock(() => {}); + render(); + expect(capturedGenerateCount).toBe(onGenerateCount); + }); // The version tests mutate a process-wide value. The file happens to run alone // in its group today, but that isolation is incidental - restore it explicitly // so a later regrouping cannot turn this into an order-dependent flake. diff --git a/tests/hooks/use-tab-manager.test.ts b/tests/hooks/use-tab-manager.test.ts index e88e3162..f0200727 100644 --- a/tests/hooks/use-tab-manager.test.ts +++ b/tests/hooks/use-tab-manager.test.ts @@ -307,6 +307,59 @@ describe("useTabManager", () => { expect(result.current.activeTabId).toBe(newTab.id); }); + test("handleGenerateCount opens an editable count without executing or replacing another query", () => { + const { result } = renderHook(() => + useTabManager({ + activeConnection: makeConnection(), + metadata: defaultMetadata, + schema: testSchema, + }), + ); + act(() => result.current.updateCurrentTab({ query: "SELECT 42;" })); + act(() => result.current.handleGenerateCount("public.Order")); + const tab = result.current.currentTab; + expect(result.current.tabs).toHaveLength(2); + expect(result.current.tabs[0].query).toBe("SELECT 42;"); + expect(tab).toMatchObject({ + name: "Count: public.Order", + query: 'SELECT COUNT(*) FROM public."Order";', + type: "sql", + result: null, + isExecuting: false, + }); + act(() => + result.current.updateCurrentTab({ + query: 'SELECT COUNT(*) FROM public."Order" WHERE active;', + }), + ); + expect(result.current.currentTab.query).toContain("WHERE active"); + act(() => result.current.handleGenerateCount("users")); + expect(result.current.tabs).toHaveLength(3); + expect(result.current.currentTab.id).not.toBe(tab.id); + }); + + test.each([ + null, + { + ...defaultMetadata, + capabilities: { + ...defaultMetadata.capabilities, + queryLanguage: "json" as const, + }, + }, + ])("handleGenerateCount ignores absent or non-SQL capabilities", (metadata) => { + const { result } = renderHook(() => + useTabManager({ + activeConnection: makeConnection(), + metadata, + schema: testSchema, + }), + ); + const before = result.current.tabs; + act(() => result.current.handleGenerateCount("users")); + expect(result.current.tabs).toEqual(before); + }); + test("setActiveTabId changes the active tab", () => { const { result } = renderHook(() => useTabManager({ diff --git a/tests/unit/lib/query-generators.test.ts b/tests/unit/lib/query-generators.test.ts index 834909ee..f08d0590 100644 --- a/tests/unit/lib/query-generators.test.ts +++ b/tests/unit/lib/query-generators.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"; import { generateTableQuery, generateSelectQuery, + generateCountQuery, shouldRefreshSchema, quoteIdentifier, quoteQualifiedName, @@ -34,6 +35,34 @@ const sampleColumns: ColumnSchema[] = [ { name: "name", type: "varchar(255)", nullable: false, isPrimary: false }, ]; +describe("generateCountQuery", () => { + test.each([ + [makeCaps(), "public.Order", 'SELECT COUNT(*) FROM public."Order";'], + [makeCaps({ defaultPort: 3306 }), "odd`table", "SELECT COUNT(*) FROM `odd``table`;"], + [makeCaps({ defaultPort: 1433 }), "odd]table", "SELECT COUNT(*) FROM [odd]]table];"], + [makeCaps({ defaultPort: 1521 }), "APP.users", 'SELECT COUNT(*) FROM APP."users";'], + [makeCaps({ defaultPort: 8091 }), "bucket.scope.orders", "SELECT COUNT(*) FROM `bucket`.`scope`.`orders`;"], + [makeCaps({ defaultPort: 8888 }), "druid.orders", 'SELECT COUNT(*) FROM "druid"."orders";'], + [ + makeCaps({ identifierQuoting: "double", statementTerminator: "none" }), + 'odd"index', + 'SELECT COUNT(*) FROM "odd""index"', + ], + [ + makeCaps({ identifierQuoting: "backtick", statementTerminator: "none" }), + "order-items", + "SELECT COUNT(*) FROM `order-items`", + ], + ])("quotes %s / %s and respects the statement terminator", (capabilities, table, expected) => { + expect(generateCountQuery(table, capabilities)).toBe(expected); + }); + + test("refuses non-SQL providers and derived key groupings", () => { + expect(generateCountQuery("users", makeCaps({ queryLanguage: "json" }))).toBeNull(); + expect(generateCountQuery("users:*", makeCaps({ tablesAreDerivedGroupings: true }))).toBeNull(); + }); +}); + // ============================================================================ // generateTableQuery // ============================================================================