{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 9cb900235..8d1fdc110 100644
--- a/src/components/sidebar/Sidebar.tsx
+++ b/src/components/sidebar/Sidebar.tsx
@@ -24,6 +24,7 @@ interface SidebarProps {
onAddConnection: () => void;
onTableClick?: (tableName: string) => void;
onGenerateSelect?: (tableName: string) => void;
+ onGenerateCount?: (tableName: string) => void;
onCreateTableClick?: () => void;
onShowDiagram?: () => void;
isAdmin?: boolean;
@@ -47,6 +48,7 @@ export function Sidebar({
onAddConnection,
onTableClick,
onGenerateSelect,
+ onGenerateCount,
onCreateTableClick,
onShowDiagram,
isAdmin = false,
@@ -107,6 +109,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 440fc16e6..667381207 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 b96289955..690a432d7 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 e4622fca0..42fc3090e 100644
--- a/src/workspace/StudioWorkspace.tsx
+++ b/src/workspace/StudioWorkspace.tsx
@@ -320,6 +320,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 3da91062c..c77e941d3 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,
})),
}));
@@ -533,6 +535,7 @@ describe("Studio", () => {
mockUpdateTabById.mockClear();
mockHandleTableClick.mockClear();
mockHandleGenerateSelect.mockClear();
+ mockHandleGenerateCount.mockClear();
mockResetTransactionState.mockClear();
mockHandleTransaction.mockClear();
mockSetPlaygroundMode.mockClear();
@@ -1582,6 +1585,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 187045d6e..4161b3c33 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();
@@ -694,6 +697,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 ad0ef33e6..c674ae71f 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 7b372e4c5..92e08bb3f 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 3fea5943c..47101a329 100644
--- a/tests/components/sidebar/Sidebar.test.tsx
+++ b/tests/components/sidebar/Sidebar.test.tsx
@@ -23,8 +23,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;
@@ -115,6 +117,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 e88e3162b..f0200727f 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 834909ee4..f08d05901 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
// ============================================================================