diff --git a/runtime/feature_flags.go b/runtime/feature_flags.go index 7448dfc3eb3..0dcd46bf01f 100644 --- a/runtime/feature_flags.go +++ b/runtime/feature_flags.go @@ -58,6 +58,9 @@ var defaultFeatureFlags = map[string]string{ "deploy": "true", // Controls if the developer agent tool is available. "developer_agent": "true", + // Controls visibility of the SQL query editor in Rill Cloud. + // Set to "true" in rill.yaml to enable. + "query_editor": "false", // Controls if the dashboard state is persisted when navigating to a different dashboard. "sticky_dashboard_state": "false", } diff --git a/runtime/feature_flags_test.go b/runtime/feature_flags_test.go index f492fa5c0d1..6be4c54932a 100644 --- a/runtime/feature_flags_test.go +++ b/runtime/feature_flags_test.go @@ -42,6 +42,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "chatCharts": true, "deploy": true, "developerAgent": true, + "queryEditor": false, "stickyDashboardState": false, }, }, @@ -66,6 +67,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "chatCharts": true, "deploy": true, "developerAgent": true, + "queryEditor": false, "stickyDashboardState": false, }, }, @@ -90,6 +92,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "chatCharts": true, "deploy": true, "developerAgent": true, + "queryEditor": false, "stickyDashboardState": false, }, }, @@ -114,6 +117,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "chatCharts": true, "deploy": true, "developerAgent": true, + "queryEditor": false, "stickyDashboardState": false, }, }, diff --git a/web-admin/src/features/navigation/TopNavigationBar.svelte b/web-admin/src/features/navigation/TopNavigationBar.svelte index fbe7460e134..92ad5deacc2 100644 --- a/web-admin/src/features/navigation/TopNavigationBar.svelte +++ b/web-admin/src/features/navigation/TopNavigationBar.svelte @@ -40,6 +40,7 @@ isOrganizationPage, isProjectPage, isPublicURLPage, + isQueryPage, } from "./nav-utils"; export let createMagicAuthTokens: boolean; @@ -77,6 +78,7 @@ $: onCanvasDashboardPage = isCanvasDashboardPage($page); $: onPublicURLPage = isPublicURLPage($page); $: onOrgPage = isOrganizationPage($page); + $: onQueryPage = isQueryPage($page); // When "View As" is active, fetch deployment credentials for the mocked user. // TanStack Query deduplicates by query key, so if the project layout already @@ -352,6 +354,10 @@ {/if} + + {#if onQueryPage && $dashboardChat} + + {/if} {/key} {/if} diff --git a/web-admin/src/features/navigation/nav-utils.ts b/web-admin/src/features/navigation/nav-utils.ts index 52f283f4472..31191c9790b 100644 --- a/web-admin/src/features/navigation/nav-utils.ts +++ b/web-admin/src/features/navigation/nav-utils.ts @@ -41,6 +41,10 @@ export function isCanvasDashboardPage(page: Page): boolean { return page.route.id === "/[organization]/[project]/canvas/[dashboard]"; } +export function isQueryPage(page: Page): boolean { + return page.route.id === "/[organization]/[project]/-/query"; +} + /** * Returns true if the page is any kind of dashboard page (either a Metrics Explorer or a Custom Dashboard). */ diff --git a/web-admin/src/features/projects/ProjectTabs.svelte b/web-admin/src/features/projects/ProjectTabs.svelte index dca951e792e..d8ff7b06c45 100644 --- a/web-admin/src/features/projects/ProjectTabs.svelte +++ b/web-admin/src/features/projects/ProjectTabs.svelte @@ -12,7 +12,7 @@ export let project: string; export let pathname: string; - const { chat, reports, alerts } = featureFlags; + const { chat, queryEditor, reports, alerts } = featureFlags; $: tabs = [ { @@ -33,7 +33,7 @@ { route: `/${organization}/${project}/-/query`, label: "Query", - hasPermission: false, + hasPermission: $queryEditor, }, { route: `/${organization}/${project}/-/reports`, diff --git a/web-admin/src/routes/[organization]/[project]/-/query/+page.svelte b/web-admin/src/routes/[organization]/[project]/-/query/+page.svelte new file mode 100644 index 00000000000..8d46a9a9451 --- /dev/null +++ b/web-admin/src/routes/[organization]/[project]/-/query/+page.svelte @@ -0,0 +1,23 @@ + + +
+
+ +
+ {#if $dashboardChat && $chatOpen} + + {/if} +
diff --git a/web-common/src/features/connectors/explorer/TableEntry.svelte b/web-common/src/features/connectors/explorer/TableEntry.svelte index f0621c2d1bc..b7f27a75107 100644 --- a/web-common/src/features/connectors/explorer/TableEntry.svelte +++ b/web-common/src/features/connectors/explorer/TableEntry.svelte @@ -32,7 +32,12 @@ $: expandedStore = store.getItem(connector, database, databaseSchema, table); $: showSchema = $expandedStore; - const { allowContextMenu, allowNavigateToTable, allowShowSchema } = store; + const { + allowContextMenu, + allowNavigateToTable, + allowShowSchema, + onInsertTable, + } = store; $: isModelingSupportedForConnector = useIsModelingSupportedForConnector( client, @@ -94,6 +99,18 @@ + {#if onInsertTable} + + {/if} + {#if allowContextMenu && (showGenerateMetricsAndDashboard || isModelingSupported || showGenerateModel)} @@ -161,4 +178,19 @@ .selected:hover { @apply bg-gray-200; } + + .insert-button { + @apply hidden flex-none items-center justify-center; + @apply w-5 h-5 rounded text-xs font-semibold; + @apply text-fg-secondary bg-transparent; + } + + .table-entry-header:hover .insert-button, + .open .insert-button { + @apply flex; + } + + .insert-button:hover { + @apply text-fg-primary bg-gray-200; + } diff --git a/web-common/src/features/connectors/explorer/TableSchema.svelte b/web-common/src/features/connectors/explorer/TableSchema.svelte index 1b96278185a..fadc8f5736a 100644 --- a/web-common/src/features/connectors/explorer/TableSchema.svelte +++ b/web-common/src/features/connectors/explorer/TableSchema.svelte @@ -2,6 +2,7 @@ import Tooltip from "../../../components/tooltip/Tooltip.svelte"; import TooltipContent from "../../../components/tooltip/TooltipContent.svelte"; import { extractErrorMessage } from "../../../lib/errors"; + import { prettyPrintType } from "../../query/query-utils"; import { useGetTable } from "../selectors"; import { useRuntimeClient } from "../../../runtime-client/v2"; @@ -31,12 +32,6 @@ $: error = $newTableQuery?.error; $: isError = !!$newTableQuery?.error; $: isLoading = $newTableQuery?.isLoading; - - function prettyPrintType(type: string) { - // Remove CODE_ prefix and normalize unsupported types to just "UNKNOWN" - const normalized = type.replace(/^CODE_/, ""); - return normalized.startsWith("UNKNOWN(") ? "UNKNOWN" : normalized; - }
    diff --git a/web-common/src/features/connectors/explorer/connector-explorer-store.ts b/web-common/src/features/connectors/explorer/connector-explorer-store.ts index aa5e97f22bd..68cf7fae775 100644 --- a/web-common/src/features/connectors/explorer/connector-explorer-store.ts +++ b/web-common/src/features/connectors/explorer/connector-explorer-store.ts @@ -21,6 +21,17 @@ export class ConnectorExplorerStore { table?: string, ) => void) = undefined; + /** Optional callback shown as a "+" button on table rows */ + onInsertTable: + | undefined + | (( + driver: string, + connector: string, + database: string, + schema: string, + table: string, + ) => void) = undefined; + constructor( { allowNavigateToTable = true, @@ -32,19 +43,29 @@ export class ConnectorExplorerStore { expandedItems = {}, localStorage = true, } = {}, - onToggleItem?: ( - connector: string, - database?: string, - schema?: string, - table?: string, - ) => void, + callbacks?: { + onToggleItem?: ( + connector: string, + database?: string, + schema?: string, + table?: string, + ) => void; + onInsertTable?: ( + driver: string, + connector: string, + database: string, + schema: string, + table: string, + ) => void; + }, ) { this.allowNavigateToTable = allowNavigateToTable; this.allowContextMenu = allowContextMenu; this.allowShowSchema = allowShowSchema; this.allowSelectTable = allowSelectTable; - if (onToggleItem) this.onToggleItem = onToggleItem; + if (callbacks?.onToggleItem) this.onToggleItem = callbacks.onToggleItem; + if (callbacks?.onInsertTable) this.onInsertTable = callbacks.onInsertTable; this.store = localStorage ? localStorageStore("connector-explorer-state", { @@ -94,7 +115,7 @@ export class ConnectorExplorerStore { showConnectors: state.showConnectors, expandedItems: {}, }, - onToggleItem ?? this.onToggleItem, + { onToggleItem: onToggleItem ?? this.onToggleItem }, ); } diff --git a/web-common/src/features/feature-flags.ts b/web-common/src/features/feature-flags.ts index 4d0b5a2f06d..f74c2b8d98e 100644 --- a/web-common/src/features/feature-flags.ts +++ b/web-common/src/features/feature-flags.ts @@ -63,6 +63,7 @@ class FeatureFlags { dashboardChat = new FeatureFlag("user", false); developerChat = new FeatureFlag("user", false); deploy = new FeatureFlag("user", true); + queryEditor = new FeatureFlag("user", false); stickyDashboardState = new FeatureFlag("user", false); private flagsUnsub?: () => void; diff --git a/web-common/src/features/query/ConnectorSelector.svelte b/web-common/src/features/query/ConnectorSelector.svelte new file mode 100644 index 00000000000..c85d0958800 --- /dev/null +++ b/web-common/src/features/query/ConnectorSelector.svelte @@ -0,0 +1,50 @@ + + + + + + {#if cell.limit === undefined} + + Server default (10,000 rows) applies. Adjustable via + rill.interactive_sql_row_limit. + + {/if} + +
    + {#if cell.isExecuting} +
    + + Running... +
    + {:else if cell.result && (hasExecuted || cell.lastRowCount)} + + {formatInteger(rowCount)} + {rowCount === 1 ? "row" : "rows"} + {#if cell.executionTimeMs !== null} + in {formatExecutionTime(cell.executionTimeMs)} + {/if} + + {/if} + + {#if cell.isExecuting} + + {:else} + + {/if} + + {#if hasResults} + + + + + + downloadResultsAsCSV(schema, data)} + > + Download as CSV + + downloadResultsAsJSON(schema, data)} + > + Download as JSON + + + + {/if} + + {#if canDelete} + + {/if} +
    + + + + {#if !cell.collapsed} +
    +
    + + + +
    + + {#if cell.error} + + +
    + + {cell.error} +
    + {/if} + +
    + +
    + + {#if cell.result || cell.isExecuting} +
    +
    + {#if cell.isExecuting} +
    + +
    + {:else} + + {/if} +
    +
    + +
    + +
    + {/if} +
    + {/if} + +{/if} + + diff --git a/web-common/src/features/query/QueryEditor.svelte b/web-common/src/features/query/QueryEditor.svelte new file mode 100644 index 00000000000..8aee0d99f9b --- /dev/null +++ b/web-common/src/features/query/QueryEditor.svelte @@ -0,0 +1,96 @@ + + +
    + + diff --git a/web-common/src/features/query/QueryResultsTable.svelte b/web-common/src/features/query/QueryResultsTable.svelte new file mode 100644 index 00000000000..a512757e576 --- /dev/null +++ b/web-common/src/features/query/QueryResultsTable.svelte @@ -0,0 +1,43 @@ + + +{#if data && data.length > 0 && columns.length > 0} + +{:else if hasExecuted} +
    +

    No rows returned

    +
    +{:else} +
    +

    Run query to view results

    +
    +{/if} + + diff --git a/web-common/src/features/query/QuerySchemaPanel.svelte b/web-common/src/features/query/QuerySchemaPanel.svelte new file mode 100644 index 00000000000..d2b6e5b9380 --- /dev/null +++ b/web-common/src/features/query/QuerySchemaPanel.svelte @@ -0,0 +1,214 @@ + + + +
    + {#if selectedTable} + + +

    + {selectedTable.objectName} +

    +
    + +

    + {selectedTable.connector} +

    +
    + + {#if selectedTable.databaseSchema} +

    + {selectedTable.database}.{selectedTable.databaseSchema} +

    + {/if} +
    + + {#if tableColumns.length > 0} + {formatInteger(tableColumns.length)} + {tableColumns.length === 1 ? "column" : "columns"} + {/if} + +
    + +
    + +
    +
    + + Columns + +
    + + {#if showTableColumns} +
    + {#if tableLoading} +

    Loading...

    + {:else if tableError} +

    + {extractErrorMessage(tableError)} +

    + {:else if tableColumns.length > 0} +
      + {#each tableColumns as column (column.name)} +
    • + + + {column.name} + + + {column.type} + +
    • + {/each} +
    + {:else} +

    + No columns found +

    + {/if} +
    + {/if} +
    + {:else if schema} + + +

    Query results

    +
    + + {formatInteger(rowCount)} + {rowCount === 1 ? "row" : "rows"} + + + {#if executionTimeMs !== null} +

    {formatExecutionTime(executionTimeMs)}

    + {/if} +
    + + {formatInteger(columnCount)} + {columnCount === 1 ? "column" : "columns"} + +
    + +
    + +
    +
    + + Result columns + +
    + + {#if showColumns} +
    +
      + {#each resultColumns as column (column.name)} +
    • + + + {column.name} + + + {column.type} + +
    • + {/each} +
    +
    + {/if} +
    + {:else} +
    + Run a query to see schema +
    + {/if} +
    +
    + + diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte new file mode 100644 index 00000000000..8b0a51523e3 --- /dev/null +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -0,0 +1,255 @@ + + +{#if notebook} +
    + +
    +

    + Data Explorer +

    + + + + {showSchemaPanel ? "Hide" : "Show"} inspector + + +
    + + +
    + + + + + + +
    +
    + {#each cells as cell (cell.id)} + + {/each} + + +
    +
    + + + {#if showSchemaPanel} + + {/if} +
    +
    +{/if} + + diff --git a/web-common/src/features/query/query-chat-config.ts b/web-common/src/features/query/query-chat-config.ts new file mode 100644 index 00000000000..e59bcc00a52 --- /dev/null +++ b/web-common/src/features/query/query-chat-config.ts @@ -0,0 +1,20 @@ +import { + type ChatConfig, + ToolName, +} from "@rilldata/web-common/features/chat/core/types"; +import type { RuntimeServiceCompleteBody } from "@rilldata/web-common/runtime-client"; +import { readable, type Readable } from "svelte/store"; + +const emptyContext: Readable> = readable( + {}, +); + +export function createQueryChatConfig(): ChatConfig { + return { + agent: ToolName.ANALYST_AGENT, + additionalContextStoreGetter: () => emptyContext, + emptyChatLabel: "Ask questions about your data", + placeholder: "Ask a question about your data...", + minChatHeight: "min-h-[4rem]", + }; +} diff --git a/web-common/src/features/query/query-export.ts b/web-common/src/features/query/query-export.ts new file mode 100644 index 00000000000..0af8aaa7207 --- /dev/null +++ b/web-common/src/features/query/query-export.ts @@ -0,0 +1,59 @@ +import type { + V1StructType, + V1QueryResolverResponseDataItem, +} from "@rilldata/web-common/runtime-client"; + +function triggerDownload(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function getTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); +} + +function escapeCSVField(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = String(value); + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +export function downloadResultsAsCSV( + schema: V1StructType | null, + data: V1QueryResolverResponseDataItem[] | null, +) { + if (!schema?.fields || !data?.length) return; + + const columns = schema.fields.map((f) => f.name ?? ""); + const header = columns.map(escapeCSVField).join(","); + const rows = data.map((row) => + columns.map((col) => escapeCSVField(row[col])).join(","), + ); + + const csv = [header, ...rows].join("\n"); + triggerDownload(csv, `query-results-${getTimestamp()}.csv`, "text/csv"); +} + +export function downloadResultsAsJSON( + schema: V1StructType | null, + data: V1QueryResolverResponseDataItem[] | null, +) { + if (!schema?.fields || !data?.length) return; + + const json = JSON.stringify(data, null, 2); + triggerDownload( + json, + `query-results-${getTimestamp()}.json`, + "application/json", + ); +} diff --git a/web-common/src/features/query/query-store.spec.ts b/web-common/src/features/query/query-store.spec.ts new file mode 100644 index 00000000000..638a825f503 --- /dev/null +++ b/web-common/src/features/query/query-store.spec.ts @@ -0,0 +1,656 @@ +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ============================================================================= +// MOCKS +// ============================================================================= + +// Mock debounce to call the function synchronously (no delay in tests) +vi.mock("@rilldata/web-common/lib/create-debouncer", () => ({ + debounce: (fn: (...args: unknown[]) => void) => fn, +})); + +vi.mock("@rilldata/web-common/runtime-client/v2/gen/runtime-service", () => ({ + runtimeServiceQueryResolver: vi.fn(), +})); + +import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client/v2/gen/runtime-service"; +import type { V1QueryResolverResponse } from "@rilldata/web-common/runtime-client"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { createNotebook, type NotebookState } from "./query-store"; + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +const DEFAULT_CONNECTOR = "duckdb"; +const PROJECT_ID = "test-org/test-project"; +const MOCK_CLIENT = { instanceId: "test-instance" } as unknown as RuntimeClient; + +// ============================================================================= +// HELPERS +// ============================================================================= + +function getState(store: ReturnType): NotebookState { + return get(store); +} + +// ============================================================================= +// TESTS +// ============================================================================= + +describe("createNotebook", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + // --------------------------------------------------------------------------- + // Initial state + // --------------------------------------------------------------------------- + + describe("initial state", () => { + it("creates with 1 default cell using the given connector", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const state = getState(store); + + expect(state.cells).toHaveLength(1); + expect(state.cells[0].connector).toBe(DEFAULT_CONNECTOR); + expect(state.cells[0].sql).toBe(""); + expect(state.cells[0].limit).toBe(100); + expect(state.cells[0].isExecuting).toBe(false); + expect(state.cells[0].result).toBeNull(); + expect(state.cells[0].error).toBeNull(); + expect(state.cells[0].collapsed).toBe(false); + expect(state.cells[0].hasExecuted).toBe(false); + }); + + it("focuses the first cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const state = getState(store); + + expect(state.focusedCellId).toBe(state.cells[0].id); + }); + }); + + // --------------------------------------------------------------------------- + // addCell + // --------------------------------------------------------------------------- + + describe("addCell", () => { + it("appends a new cell and focuses it", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const newId = store.addCell(); + const state = getState(store); + + expect(state.cells).toHaveLength(2); + expect(state.cells[1].id).toBe(newId); + expect(state.focusedCellId).toBe(newId); + }); + + it("returns the new cell id", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const newId = store.addCell(); + + expect(typeof newId).toBe("string"); + expect(newId.length).toBeGreaterThan(0); + }); + + it("uses the default connector when none is specified", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + store.addCell(); + const state = getState(store); + + expect(state.cells[1].connector).toBe(DEFAULT_CONNECTOR); + }); + + it("uses a custom connector when specified", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + store.addCell("clickhouse"); + const state = getState(store); + + expect(state.cells[1].connector).toBe("clickhouse"); + }); + }); + + // --------------------------------------------------------------------------- + // removeCell + // --------------------------------------------------------------------------- + + describe("removeCell", () => { + it("removes the specified cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const id1 = getState(store).cells[0].id; + const id2 = store.addCell(); + + store.removeCell(id2); + const state = getState(store); + + expect(state.cells).toHaveLength(1); + expect(state.cells[0].id).toBe(id1); + }); + + it("cannot remove the last remaining cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const onlyId = getState(store).cells[0].id; + + store.removeCell(onlyId); + const state = getState(store); + + expect(state.cells).toHaveLength(1); + expect(state.cells[0].id).toBe(onlyId); + }); + + it("moves focus to the previous cell when removing the focused cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + getState(store).cells[0].id; + const id2 = store.addCell(); + const id3 = store.addCell(); + + // id3 is now focused; remove it + store.removeCell(id3); + const state = getState(store); + + expect(state.focusedCellId).toBe(id2); + }); + + it("moves focus to first cell when removing the first focused cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const id1 = getState(store).cells[0].id; + const id2 = store.addCell(); + + // Focus the first cell, then remove it + store.setFocusedCell(id1); + store.removeCell(id1); + const state = getState(store); + + expect(state.focusedCellId).toBe(id2); + }); + + it("does not change focus when removing an unfocused cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const id1 = getState(store).cells[0].id; + store.addCell(); + const id3 = store.addCell(); + + // id3 is focused; remove id1 + store.removeCell(id1); + const state = getState(store); + + expect(state.focusedCellId).toBe(id3); + }); + }); + + // --------------------------------------------------------------------------- + // setCellSql + // --------------------------------------------------------------------------- + + describe("setCellSql", () => { + it("updates the SQL of the specified cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.setCellSql(cellId, "SELECT 1"); + const state = getState(store); + + expect(state.cells[0].sql).toBe("SELECT 1"); + }); + }); + + // --------------------------------------------------------------------------- + // setCellConnector + // --------------------------------------------------------------------------- + + describe("setCellConnector", () => { + it("updates the connector of the specified cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.setCellConnector(cellId, "postgres"); + const state = getState(store); + + expect(state.cells[0].connector).toBe("postgres"); + }); + }); + + // --------------------------------------------------------------------------- + // setCellLimit + // --------------------------------------------------------------------------- + + describe("setCellLimit", () => { + it("sets the limit to the provided value", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.setCellLimit(cellId, 50); + const state = getState(store); + + expect(state.cells[0].limit).toBe(50); + }); + + it("clamps to a minimum of 1", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.setCellLimit(cellId, 0); + const state = getState(store); + + expect(state.cells[0].limit).toBe(1); + }); + + it("clamps negative values to 1", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.setCellLimit(cellId, -10); + const state = getState(store); + + expect(state.cells[0].limit).toBe(1); + }); + + it("sets undefined for no limit", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.setCellLimit(cellId, undefined); + const state = getState(store); + + expect(state.cells[0].limit).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // toggleCellCollapsed + // --------------------------------------------------------------------------- + + describe("toggleCellCollapsed", () => { + it("toggles collapsed from false to true", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.toggleCellCollapsed(cellId); + + expect(getState(store).cells[0].collapsed).toBe(true); + }); + + it("toggles collapsed back to false", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + + store.toggleCellCollapsed(cellId); + store.toggleCellCollapsed(cellId); + + expect(getState(store).cells[0].collapsed).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // setFocusedCell + // --------------------------------------------------------------------------- + + describe("setFocusedCell", () => { + it("changes the focused cell", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const id1 = getState(store).cells[0].id; + store.addCell(); + + store.setFocusedCell(id1); + + expect(getState(store).focusedCellId).toBe(id1); + }); + }); + + // --------------------------------------------------------------------------- + // executeCellQuery + // --------------------------------------------------------------------------- + + describe("executeCellQuery", () => { + it("sets result and hasExecuted on success", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + + const mockResponse = { + schema: { fields: [{ name: "col1", type: { code: "CODE_INT32" } }] }, + data: [{ col1: 1 }], + } as V1QueryResolverResponse; + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue(mockResponse); + + await store.executeCellQuery(cellId, MOCK_CLIENT); + const cell = getState(store).cells[0]; + + expect(cell.result).toEqual(mockResponse); + expect(cell.hasExecuted).toBe(true); + expect(cell.isExecuting).toBe(false); + expect(cell.error).toBeNull(); + expect(cell.executionTimeMs).toBeTypeOf("number"); + expect(cell.lastRowCount).toBe(1); + }); + + it("sets isExecuting to true while query is in flight", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + + // Use a deferred promise so we can inspect state mid-execution + let resolveQuery!: (value: unknown) => void; + vi.mocked(runtimeServiceQueryResolver).mockReturnValue( + new Promise((resolve) => { + resolveQuery = resolve; + }), + ); + + const promise = store.executeCellQuery(cellId, MOCK_CLIENT); + + // While in flight, isExecuting should be true + expect(getState(store).cells[0].isExecuting).toBe(true); + + resolveQuery({ schema: null, data: [] }); + await promise; + + expect(getState(store).cells[0].isExecuting).toBe(false); + }); + + it("sets error and hasExecuted on failure", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT bad_column"); + + vi.mocked(runtimeServiceQueryResolver).mockRejectedValue( + new Error("Syntax error"), + ); + + await store.executeCellQuery(cellId, MOCK_CLIENT); + const cell = getState(store).cells[0]; + + expect(cell.error).toBe("Syntax error"); + expect(cell.hasExecuted).toBe(true); + expect(cell.isExecuting).toBe(false); + }); + + it("extracts error message from response.data.message", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + + const apiError = { + response: { data: { message: "API-level error" } }, + }; + vi.mocked(runtimeServiceQueryResolver).mockRejectedValue(apiError); + + await store.executeCellQuery(cellId, MOCK_CLIENT); + + expect(getState(store).cells[0].error).toBe("API-level error"); + }); + + it("does nothing when cell SQL is empty", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + // SQL is "" by default + + await store.executeCellQuery(cellId, MOCK_CLIENT); + + expect(runtimeServiceQueryResolver).not.toHaveBeenCalled(); + expect(getState(store).cells[0].isExecuting).toBe(false); + }); + + it("does nothing when cell SQL is only whitespace", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, " "); + + await store.executeCellQuery(cellId, MOCK_CLIENT); + + expect(runtimeServiceQueryResolver).not.toHaveBeenCalled(); + }); + + it("uses sqlOverride instead of cell SQL when provided", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT original"); + + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue({ + schema: undefined, + data: [], + }); + + await store.executeCellQuery(cellId, MOCK_CLIENT, "SELECT override"); + + expect(runtimeServiceQueryResolver).toHaveBeenCalledWith( + MOCK_CLIENT, + expect.objectContaining({ + resolverProperties: expect.objectContaining({ + sql: "SELECT override", + }), + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("passes connector and limit in the request body", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + store.setCellLimit(cellId, 25); + + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue({ + schema: undefined, + data: [], + }); + + await store.executeCellQuery(cellId, MOCK_CLIENT); + + expect(runtimeServiceQueryResolver).toHaveBeenCalledWith( + MOCK_CLIENT, + expect.objectContaining({ + resolver: "sql", + resolverProperties: { + sql: "SELECT 1", + connector: DEFAULT_CONNECTOR, + }, + limit: 25, + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("omits limit from the request when cell limit is undefined", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + store.setCellLimit(cellId, undefined); + + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue({ + schema: undefined, + data: [], + }); + + await store.executeCellQuery(cellId, MOCK_CLIENT); + + const callArgs = vi.mocked(runtimeServiceQueryResolver).mock.calls[0][1]; + expect(callArgs).not.toHaveProperty("limit"); + }); + + it("focuses the executed cell", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const id1 = getState(store).cells[0].id; + store.addCell(); + + store.setCellSql(id1, "SELECT 1"); + // id2 is focused after addCell; executing id1 should refocus it + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue({ + schema: undefined, + data: [], + }); + + await store.executeCellQuery(id1, MOCK_CLIENT); + + expect(getState(store).focusedCellId).toBe(id1); + }); + + it("does nothing for a nonexistent cell id", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + + await store.executeCellQuery("nonexistent-id", MOCK_CLIENT); + + expect(runtimeServiceQueryResolver).not.toHaveBeenCalled(); + }); + + it("aborts previous in-flight query when re-executed", async () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + + let rejectFirst!: (reason: unknown) => void; + vi.mocked(runtimeServiceQueryResolver) + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFirst = reject; + }), + ) + .mockResolvedValueOnce({ schema: undefined, data: [] }); + + // Fire first execution (will be in-flight) + const first = store.executeCellQuery(cellId, MOCK_CLIENT); + expect(getState(store).cells[0].isExecuting).toBe(true); + + // Second call aborts the first and starts a new execution + const second = store.executeCellQuery(cellId, MOCK_CLIENT); + + // The first call's signal should be aborted + const firstSignal = vi.mocked(runtimeServiceQueryResolver).mock + .calls[0][2]?.signal; + expect(firstSignal?.aborted).toBe(true); + + // Both calls were made + expect(runtimeServiceQueryResolver).toHaveBeenCalledTimes(2); + + // Resolve/reject the first (should be ignored due to abort) + rejectFirst(new DOMException("aborted", "AbortError")); + await first; + await second; + + // Cell should have completed from the second call + expect(getState(store).cells[0].isExecuting).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Per-project localStorage isolation + // --------------------------------------------------------------------------- + + describe("per-project localStorage", () => { + it("persists to a project-scoped key", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "SELECT 1"); + + const stored = localStorage.getItem(`rill:query-notebook:${PROJECT_ID}`); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed[0].sql).toBe("SELECT 1"); + }); + + it("isolates state between different projects", () => { + const storeA = createNotebook(DEFAULT_CONNECTOR, "org/project-a"); + const cellA = getState(storeA).cells[0].id; + storeA.setCellSql(cellA, "SELECT a"); + + const storeB = createNotebook(DEFAULT_CONNECTOR, "org/project-b"); + const cellB = getState(storeB).cells[0].id; + storeB.setCellSql(cellB, "SELECT b"); + + const storedA = JSON.parse( + localStorage.getItem("rill:query-notebook:org/project-a")!, + ); + const storedB = JSON.parse( + localStorage.getItem("rill:query-notebook:org/project-b")!, + ); + + expect(storedA[0].sql).toBe("SELECT a"); + expect(storedB[0].sql).toBe("SELECT b"); + }); + }); + + // --------------------------------------------------------------------------- + // destroy + // --------------------------------------------------------------------------- + + describe("destroy", () => { + it("stops persisting after destroy is called", () => { + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cellId = getState(store).cells[0].id; + store.setCellSql(cellId, "before destroy"); + + const key = `rill:query-notebook:${PROJECT_ID}`; + const before = localStorage.getItem(key); + expect(before).not.toBeNull(); + + store.destroy(); + localStorage.removeItem(key); + + store.setCellSql(cellId, "after destroy"); + expect(localStorage.getItem(key)).toBeNull(); + }); + }); + + // --------------------------------------------------------------------------- + // Hydration + // --------------------------------------------------------------------------- + + describe("hydration", () => { + it("restores schema but marks hasExecuted as false (no live data)", () => { + const key = `rill:query-notebook:${PROJECT_ID}`; + const persisted = [ + { + id: "cell-1", + sql: "SELECT 1", + connector: DEFAULT_CONNECTOR, + limit: 100, + collapsed: false, + resultSchema: { + fields: [{ name: "col1", type: { code: "CODE_INT32" } }], + }, + resultRowCount: 1, + executionTimeMs: 50, + }, + ]; + localStorage.setItem(key, JSON.stringify(persisted)); + + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cell = getState(store).cells[0]; + + expect(cell.hasExecuted).toBe(false); + expect(cell.result).not.toBeNull(); + expect(cell.result?.schema).not.toBeNull(); + expect(cell.sql).toBe("SELECT 1"); + }); + + it("restores hasExecuted as false when no schema was persisted", () => { + const key = `rill:query-notebook:${PROJECT_ID}`; + const persisted = [ + { + id: "cell-2", + sql: "SELECT 1", + connector: DEFAULT_CONNECTOR, + limit: 100, + collapsed: false, + resultSchema: null, + resultRowCount: null, + executionTimeMs: null, + }, + ]; + localStorage.setItem(key, JSON.stringify(persisted)); + + const store = createNotebook(DEFAULT_CONNECTOR, PROJECT_ID); + const cell = getState(store).cells[0]; + + expect(cell.hasExecuted).toBe(false); + expect(cell.result).toBeNull(); + }); + }); +}); diff --git a/web-common/src/features/query/query-store.ts b/web-common/src/features/query/query-store.ts new file mode 100644 index 00000000000..0a609d923e1 --- /dev/null +++ b/web-common/src/features/query/query-store.ts @@ -0,0 +1,349 @@ +import { browser } from "$app/environment"; +import { writable, derived, get } from "svelte/store"; +import { debounce } from "@rilldata/web-common/lib/create-debouncer"; +import { extractErrorMessage } from "@rilldata/web-common/lib/errors"; +import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client/v2/gen/runtime-service"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import type { + V1QueryResolverResponse, + V1StructType, +} from "@rilldata/web-common/runtime-client"; + +export interface CellState { + id: string; + sql: string; + connector: string; + limit: number | undefined; // undefined = no limit + isExecuting: boolean; + result: V1QueryResolverResponse | null; + error: string | null; + executionTimeMs: number | null; + lastRowCount: number | null; // persisted row count from last execution + collapsed: boolean; + hasExecuted: boolean; // true after query runs this session +} + +export interface NotebookState { + cells: CellState[]; + focusedCellId: string | null; +} + +const DEFAULT_LIMIT = 100; +const STORAGE_KEY_PREFIX = "rill:query-notebook"; + +interface PersistedCell { + id: string; + sql: string; + connector: string; + limit: number | undefined; + collapsed: boolean; + resultSchema: V1StructType | null; + resultRowCount: number | null; + executionTimeMs: number | null; +} + +function storageKey(projectId: string): string { + return projectId ? `${STORAGE_KEY_PREFIX}:${projectId}` : STORAGE_KEY_PREFIX; +} + +function loadPersistedCells(projectId: string): PersistedCell[] | null { + if (!browser) return null; + try { + const stored = localStorage.getItem(storageKey(projectId)); + if (!stored) return null; + const parsed = JSON.parse(stored); + if (Array.isArray(parsed) && parsed.length > 0) return parsed; + } catch { + // ignore corrupt data + } + return null; +} + +function saveToLocalStorage(projectId: string, cells: CellState[]) { + if (!browser) return; + try { + const persisted: PersistedCell[] = cells.map((c) => ({ + id: c.id, + sql: c.sql, + connector: c.connector, + limit: c.limit, + collapsed: c.collapsed, + resultSchema: c.result?.schema ?? null, + resultRowCount: c.result?.data?.length ?? null, + executionTimeMs: c.executionTimeMs, + })); + localStorage.setItem(storageKey(projectId), JSON.stringify(persisted)); + } catch { + // QuotaExceededError or other storage failures; silently ignore + } +} + +function hydrateCell(p: PersistedCell): CellState { + // Restore schema into a minimal result so the inspector can display it + const hasSchema = p.resultSchema && p.resultSchema.fields?.length; + return { + id: p.id, + sql: p.sql, + connector: p.connector, + limit: p.limit, + collapsed: p.collapsed, + isExecuting: false, + result: hasSchema ? { schema: p.resultSchema!, data: [] } : null, + error: null, + executionTimeMs: p.executionTimeMs ?? null, + lastRowCount: p.resultRowCount ?? null, + hasExecuted: false, + }; +} + +function createDefaultCell(connector: string): CellState { + return { + id: crypto.randomUUID(), + sql: "", + connector, + limit: DEFAULT_LIMIT, + isExecuting: false, + result: null, + error: null, + executionTimeMs: null, + lastRowCount: null, + collapsed: false, + hasExecuted: false, + }; +} + +function updateCell( + state: NotebookState, + cellId: string, + updater: (cell: CellState) => CellState, +): NotebookState { + return { + ...state, + cells: state.cells.map((c) => (c.id === cellId ? updater(c) : c)), + }; +} + +function createNotebookStore(defaultConnector: string, projectId: string) { + const persisted = loadPersistedCells(projectId); + const initialCells = persisted + ? persisted.map(hydrateCell) + : [createDefaultCell(defaultConnector)]; + + const state = writable({ + cells: initialCells, + focusedCellId: initialCells[0]?.id ?? null, + }); + + // Only persist when we have a real connector and project ID + let unsubPersist: (() => void) | undefined; + if (defaultConnector && projectId) { + const debouncedSave = debounce( + (cells: CellState[]) => saveToLocalStorage(projectId, cells), + 500, + ); + unsubPersist = state.subscribe(($s) => debouncedSave($s.cells)); + } + + const { subscribe, update } = state; + + function addCell(connector?: string) { + const cell = createDefaultCell(connector ?? defaultConnector); + update((s) => ({ + ...s, + cells: [...s.cells, cell], + focusedCellId: cell.id, + })); + return cell.id; + } + + function removeCell(cellId: string) { + update((s) => { + if (s.cells.length <= 1) return s; // keep at least 1 cell + + const idx = s.cells.findIndex((c) => c.id === cellId); + const newCells = s.cells.filter((c) => c.id !== cellId); + + let newFocused = s.focusedCellId; + if (s.focusedCellId === cellId) { + // Move focus to previous cell, or first if removed was first + const newIdx = Math.max(0, idx - 1); + newFocused = newCells[newIdx]?.id ?? newCells[0]?.id ?? null; + } + + return { cells: newCells, focusedCellId: newFocused }; + }); + } + + function setCellSql(cellId: string, sql: string) { + update((s) => updateCell(s, cellId, (c) => ({ ...c, sql }))); + } + + function setCellConnector(cellId: string, connector: string) { + update((s) => updateCell(s, cellId, (c) => ({ ...c, connector }))); + } + + function setCellLimit(cellId: string, limit: number | undefined) { + update((s) => + updateCell(s, cellId, (c) => ({ + ...c, + limit: limit !== undefined ? Math.max(1, limit) : undefined, + })), + ); + } + + function toggleCellCollapsed(cellId: string) { + update((s) => + updateCell(s, cellId, (c) => ({ ...c, collapsed: !c.collapsed })), + ); + } + + function setFocusedCell(cellId: string) { + update((s) => ({ ...s, focusedCellId: cellId })); + } + + // Per-cell abort controllers for query cancellation + const abortControllers = new Map(); + + async function executeCellQuery( + cellId: string, + client: RuntimeClient, + sqlOverride?: string, + ) { + const current = get(state); + const cell = current.cells.find((c) => c.id === cellId); + if (!cell) return; + + const sqlToRun = (sqlOverride ?? cell.sql).trim(); + if (!sqlToRun) return; + + // Abort any in-flight query for this cell + abortControllers.get(cellId)?.abort(); + const controller = new AbortController(); + abortControllers.set(cellId, controller); + + update((s) => ({ + ...updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: true, + error: null, + })), + focusedCellId: cellId, + })); + + const startTime = performance.now(); + + try { + const response = await runtimeServiceQueryResolver( + client, + { + resolver: "sql", + resolverProperties: { + sql: sqlToRun, + connector: cell.connector, + }, + ...(cell.limit !== undefined ? { limit: cell.limit } : {}), + } as Parameters[1], + { signal: controller.signal }, + ); + const elapsed = Math.round(performance.now() - startTime); + + update((s) => + updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: false, + result: response, + error: null, + executionTimeMs: elapsed, + lastRowCount: response.data?.length ?? 0, + hasExecuted: true, + })), + ); + } catch (err: unknown) { + // Ignore abort errors (user cancelled or re-ran) + if (controller.signal.aborted) return; + + const elapsed = Math.round(performance.now() - startTime); + const message = extractErrorMessage(err); + + update((s) => + updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: false, + error: message, + executionTimeMs: elapsed, + hasExecuted: true, + })), + ); + } finally { + abortControllers.delete(cellId); + } + } + + // Derived stores for the focused cell + const focusedCell = derived(state, ($s) => { + if (!$s.focusedCellId) return null; + return $s.cells.find((c) => c.id === $s.focusedCellId) ?? null; + }); + + const focusedSchema = derived( + focusedCell, + ($c) => $c?.result?.schema ?? null, + ); + const focusedRowCount = derived(focusedCell, ($c) => { + // Use live data length if available; fall back to persisted row count + const liveCount = $c?.result?.data?.length; + if (liveCount !== undefined) return liveCount; + return $c?.lastRowCount ?? 0; + }); + const focusedExecutionTimeMs = derived( + focusedCell, + ($c) => $c?.executionTimeMs ?? null, + ); + + function cancelCellQuery(cellId: string) { + const controller = abortControllers.get(cellId); + if (!controller) return; + controller.abort(); + abortControllers.delete(cellId); + update((s) => + updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: false, + })), + ); + } + + function destroy() { + unsubPersist?.(); + for (const controller of abortControllers.values()) { + controller.abort(); + } + abortControllers.clear(); + } + + return { + subscribe, + destroy, + addCell, + removeCell, + setCellSql, + setCellConnector, + setCellLimit, + toggleCellCollapsed, + setFocusedCell, + executeCellQuery, + cancelCellQuery, + focusedSchema, + focusedRowCount, + focusedExecutionTimeMs, + }; +} + +export type NotebookStore = ReturnType; + +export function createNotebook( + defaultConnector: string, + projectId: string, +): NotebookStore { + return createNotebookStore(defaultConnector, projectId); +} diff --git a/web-common/src/features/query/query-utils.spec.ts b/web-common/src/features/query/query-utils.spec.ts new file mode 100644 index 00000000000..82d893a3f44 --- /dev/null +++ b/web-common/src/features/query/query-utils.spec.ts @@ -0,0 +1,72 @@ +import { + formatExecutionTime, + prettyPrintType, +} from "@rilldata/web-common/features/query/query-utils"; +import { describe, expect, it } from "vitest"; + +describe("prettyPrintType", () => { + it("returns UNKNOWN for undefined input", () => { + expect(prettyPrintType(undefined)).toBe("UNKNOWN"); + }); + + it("returns UNKNOWN for empty string", () => { + expect(prettyPrintType("")).toBe("UNKNOWN"); + }); + + it("strips the CODE_ prefix", () => { + expect(prettyPrintType("CODE_INT32")).toBe("INT32"); + }); + + it("strips the CODE_ prefix for various types", () => { + const cases = [ + { input: "CODE_VARCHAR", expected: "VARCHAR" }, + { input: "CODE_FLOAT64", expected: "FLOAT64" }, + { input: "CODE_BOOLEAN", expected: "BOOLEAN" }, + { input: "CODE_TIMESTAMP", expected: "TIMESTAMP" }, + { input: "CODE_DATE", expected: "DATE" }, + ]; + for (const { input, expected } of cases) { + expect(prettyPrintType(input)).toBe(expected); + } + }); + + it("returns the code as-is when there is no CODE_ prefix", () => { + expect(prettyPrintType("INT32")).toBe("INT32"); + }); + + it("returns UNKNOWN for UNKNOWN(...) type codes after stripping prefix", () => { + expect(prettyPrintType("CODE_UNKNOWN(42)")).toBe("UNKNOWN"); + }); + + it("returns UNKNOWN for bare UNKNOWN(...) without prefix", () => { + expect(prettyPrintType("UNKNOWN(999)")).toBe("UNKNOWN"); + }); + + it("returns UNKNOWN for UNKNOWN(...) with nested content", () => { + expect(prettyPrintType("UNKNOWN(some_type)")).toBe("UNKNOWN"); + }); + + it("does not treat a bare UNKNOWN string (no parens) as unknown", () => { + // "UNKNOWN" without parentheses does not start with "UNKNOWN(" + expect(prettyPrintType("UNKNOWN")).toBe("UNKNOWN"); + }); + + it("only strips the first CODE_ occurrence", () => { + // replace(/^CODE_/, "") only strips the leading prefix + expect(prettyPrintType("CODE_CODE_INT32")).toBe("CODE_INT32"); + }); +}); + +describe("formatExecutionTime", () => { + it("formats sub-second durations in milliseconds", () => { + expect(formatExecutionTime(0)).toBe("0ms"); + expect(formatExecutionTime(50)).toBe("50ms"); + expect(formatExecutionTime(999)).toBe("999ms"); + }); + + it("formats durations >= 1 second with one decimal", () => { + expect(formatExecutionTime(1000)).toBe("1.0s"); + expect(formatExecutionTime(1500)).toBe("1.5s"); + expect(formatExecutionTime(12345)).toBe("12.3s"); + }); +}); diff --git a/web-common/src/features/query/query-utils.ts b/web-common/src/features/query/query-utils.ts new file mode 100644 index 00000000000..cabdc4dd993 --- /dev/null +++ b/web-common/src/features/query/query-utils.ts @@ -0,0 +1,11 @@ +/** Converts a V1StructType field type code to a display string */ +export function prettyPrintType(code: string | undefined): string { + if (!code) return "UNKNOWN"; + const normalized = code.replace(/^CODE_/, ""); + return normalized.startsWith("UNKNOWN(") ? "UNKNOWN" : normalized; +} + +/** Formats a duration in milliseconds for display */ +export function formatExecutionTime(ms: number): string { + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; +}