From 2f3b3f69d38272ac1d41adce06e48642ded7eab0 Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:03:22 -0500 Subject: [PATCH 01/34] POC --- .../src/features/projects/ProjectTabs.svelte | 2 +- .../[project]/-/query/+page.svelte | 7 + .../features/query/ConnectorSelector.svelte | 61 +++++++ .../src/features/query/QueryEditor.svelte | 89 ++++++++++ .../query/QueryResultsInspector.svelte | 90 ++++++++++ .../features/query/QueryResultsTable.svelte | 45 +++++ .../src/features/query/QueryWorkspace.svelte | 167 ++++++++++++++++++ web-common/src/features/query/query-store.ts | 126 +++++++++++++ 8 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 web-admin/src/routes/[organization]/[project]/-/query/+page.svelte create mode 100644 web-common/src/features/query/ConnectorSelector.svelte create mode 100644 web-common/src/features/query/QueryEditor.svelte create mode 100644 web-common/src/features/query/QueryResultsInspector.svelte create mode 100644 web-common/src/features/query/QueryResultsTable.svelte create mode 100644 web-common/src/features/query/QueryWorkspace.svelte create mode 100644 web-common/src/features/query/query-store.ts diff --git a/web-admin/src/features/projects/ProjectTabs.svelte b/web-admin/src/features/projects/ProjectTabs.svelte index dca951e792e..9bed292a891 100644 --- a/web-admin/src/features/projects/ProjectTabs.svelte +++ b/web-admin/src/features/projects/ProjectTabs.svelte @@ -33,7 +33,7 @@ { route: `/${organization}/${project}/-/query`, label: "Query", - hasPermission: false, + hasPermission: true, }, { 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..8ae28cb4cf6 --- /dev/null +++ b/web-admin/src/routes/[organization]/[project]/-/query/+page.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/web-common/src/features/query/ConnectorSelector.svelte b/web-common/src/features/query/ConnectorSelector.svelte new file mode 100644 index 00000000000..f441694eefa --- /dev/null +++ b/web-common/src/features/query/ConnectorSelector.svelte @@ -0,0 +1,61 @@ + + + + + +
+ {#if $queryConsole.isExecuting} +
+ + Running... +
+ {:else if $queryConsole.result} + + {formatInteger($rowCount)} {$rowCount === 1 ? "row" : "rows"} + {#if $queryConsole.executionTimeMs !== null} + in {$queryConsole.executionTimeMs < 1000 + ? `${$queryConsole.executionTimeMs}ms` + : `${($queryConsole.executionTimeMs / 1000).toFixed(1)}s`} + {/if} + + {/if} + + +
+ + + +
+ + + + + {#if $tableVisible} + + {#if $queryConsole.isExecuting} +
+ +
+ {:else} + + {/if} +
+ {/if} +
+ + + + + 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..7856b0926cd --- /dev/null +++ b/web-common/src/features/query/query-store.ts @@ -0,0 +1,126 @@ +import { writable, derived, get } from "svelte/store"; +import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client"; +import type { + V1QueryResolverResponse, + V1StructType, + V1QueryResolverResponseDataItem, +} from "@rilldata/web-common/runtime-client"; + +export interface QueryConsoleState { + sql: string; + connector: string; + limit: number; + isExecuting: boolean; + result: V1QueryResolverResponse | null; + error: string | null; + executionTimeMs: number | null; +} + +const DEFAULT_LIMIT = 100; + +function createQueryConsoleStore() { + const state = writable({ + sql: "", + connector: "", + limit: DEFAULT_LIMIT, + isExecuting: false, + result: null, + error: null, + executionTimeMs: null, + }); + + const { subscribe, update } = state; + + function setSql(sql: string) { + update((s) => ({ ...s, sql })); + } + + function setConnector(connector: string) { + update((s) => ({ ...s, connector })); + } + + function setLimit(limit: number) { + update((s) => ({ ...s, limit: Math.max(1, limit) })); + } + + async function executeQuery(instanceId: string) { + const current = get(state); + const sql = current.sql.trim(); + if (!sql) return; + + update((s) => ({ + ...s, + isExecuting: true, + error: null, + })); + + const startTime = performance.now(); + + try { + const response = await runtimeServiceQueryResolver(instanceId, { + resolver: "sql", + resolverProperties: { + sql, + connector: current.connector, + }, + limit: current.limit, + }); + + const elapsed = Math.round(performance.now() - startTime); + + update((s) => ({ + ...s, + isExecuting: false, + result: response, + error: null, + executionTimeMs: elapsed, + })); + } catch (err: unknown) { + const elapsed = Math.round(performance.now() - startTime); + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? + (err as Error)?.message ?? + "Query execution failed"; + + update((s) => ({ + ...s, + isExecuting: false, + error: message, + executionTimeMs: elapsed, + })); + } + } + + function reset() { + update((s) => ({ + ...s, + result: null, + error: null, + executionTimeMs: null, + })); + } + + // Derived stores for convenience + const schema = derived(state, ($s) => $s.result?.schema ?? null); + const data = derived(state, ($s) => $s.result?.data ?? null); + const rowCount = derived(state, ($s) => $s.result?.data?.length ?? 0); + + return { + subscribe, + setSql, + setConnector, + setLimit, + executeQuery, + reset, + schema, + data, + rowCount, + }; +} + +export type QueryConsoleStore = ReturnType; + +export function createQueryConsole(): QueryConsoleStore { + return createQueryConsoleStore(); +} From acd8ed51891a538d4a8ebf914c87922d14e3e2ea Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:36:20 -0500 Subject: [PATCH 02/34] query cells, data explorer, + to add table instread of clcik --- .../connectors/explorer/TableEntry.svelte | 28 +- .../explorer/connector-explorer-store.ts | 19 ++ .../features/query/ConnectorSelector.svelte | 4 +- .../src/features/query/QueryCell.svelte | 269 ++++++++++++++++ .../src/features/query/QueryEditor.svelte | 28 +- .../query/QueryResultsInspector.svelte | 90 ------ .../features/query/QueryResultsTable.svelte | 10 +- .../features/query/QuerySchemaPanel.svelte | 211 +++++++++++++ .../src/features/query/QueryWorkspace.svelte | 289 ++++++++++-------- web-common/src/features/query/query-store.ts | 272 +++++++++++++---- 10 files changed, 926 insertions(+), 294 deletions(-) create mode 100644 web-common/src/features/query/QueryCell.svelte delete mode 100644 web-common/src/features/query/QueryResultsInspector.svelte create mode 100644 web-common/src/features/query/QuerySchemaPanel.svelte diff --git a/web-common/src/features/connectors/explorer/TableEntry.svelte b/web-common/src/features/connectors/explorer/TableEntry.svelte index 5a9edd94756..f49e49a3cd5 100644 --- a/web-common/src/features/connectors/explorer/TableEntry.svelte +++ b/web-common/src/features/connectors/explorer/TableEntry.svelte @@ -30,7 +30,7 @@ $: expandedStore = store.getItem(connector, database, databaseSchema, table); $: showSchema = $expandedStore; - const { allowContextMenu, allowNavigateToTable, allowShowSchema } = store; + const { allowContextMenu, allowNavigateToTable, allowShowSchema, onInsertTable } = store; $: ({ instanceId: runtimeInstanceId } = $runtime); $: isModelingSupportedForConnector = useIsModelingSupportedForConnector( @@ -93,6 +93,18 @@ + {#if onInsertTable} + + {/if} + {#if allowContextMenu && (showGenerateMetricsAndDashboard || isModelingSupported || showGenerateModel)} @@ -160,4 +172,18 @@ .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 { + @apply flex; + } + + .insert-button:hover { + @apply text-fg-primary bg-gray-200; + } 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..f2acef0158e 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, @@ -38,6 +49,13 @@ export class ConnectorExplorerStore { schema?: string, table?: string, ) => void, + onInsertTable?: ( + driver: string, + connector: string, + database: string, + schema: string, + table: string, + ) => void, ) { this.allowNavigateToTable = allowNavigateToTable; this.allowContextMenu = allowContextMenu; @@ -45,6 +63,7 @@ export class ConnectorExplorerStore { this.allowSelectTable = allowSelectTable; if (onToggleItem) this.onToggleItem = onToggleItem; + if (onInsertTable) this.onInsertTable = onInsertTable; this.store = localStorage ? localStorageStore("connector-explorer-state", { diff --git a/web-common/src/features/query/ConnectorSelector.svelte b/web-common/src/features/query/ConnectorSelector.svelte index f441694eefa..052635a4fa6 100644 --- a/web-common/src/features/query/ConnectorSelector.svelte +++ b/web-common/src/features/query/ConnectorSelector.svelte @@ -31,7 +31,9 @@ return data.connectors .filter( (c) => - c?.driver?.implementsOlap || c?.driver?.implementsSqlStore, + c?.driver?.implementsOlap || + c?.driver?.implementsSqlStore || + c?.driver?.implementsWarehouse, ) .sort((a, b) => (a?.name as string).localeCompare(b?.name as string), diff --git a/web-common/src/features/query/QueryCell.svelte b/web-common/src/features/query/QueryCell.svelte new file mode 100644 index 00000000000..acbfc93ba3e --- /dev/null +++ b/web-common/src/features/query/QueryCell.svelte @@ -0,0 +1,269 @@ + + +{#if cell} + + +
+ +
+ + + + +
+ Limit + +
+ + {#if cell.limit === undefined} + + No limit; large queries may be slow and costly. + + {/if} + +
+ {#if cell.isExecuting} +
+ + Running... +
+ {:else if cell.result} + + {formatInteger(rowCount)} {rowCount === 1 ? "row" : "rows"} + {#if cell.executionTimeMs !== null} + in {cell.executionTimeMs < 1000 + ? `${cell.executionTimeMs}ms` + : `${(cell.executionTimeMs / 1000).toFixed(1)}s`} + {/if} + + {/if} + + + + {#if canDelete} + + {/if} +
+
+ + + {#if !cell.collapsed} +
+ + + + + {#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 index 0ab7d518940..22a791dcc57 100644 --- a/web-common/src/features/query/QueryEditor.svelte +++ b/web-common/src/features/query/QueryEditor.svelte @@ -2,7 +2,6 @@ import { autocompletion } from "@codemirror/autocomplete"; import { keywordCompletionSource, - schemaCompletionSource, sql, } from "@codemirror/lang-sql"; import { Compartment, EditorState } from "@codemirror/state"; @@ -14,7 +13,7 @@ export let initialValue = ""; const dispatch = createEventDispatcher<{ - run: void; + run: { selectedText?: string }; change: string; }>(); @@ -30,12 +29,17 @@ }); } - // Cmd/Ctrl+Enter to run query + // Cmd/Ctrl+Enter to run query (selected text or full content) const runKeymap = keymap.of([ { key: "Mod-Enter", - run: () => { - dispatch("run"); + run: (view) => { + const sel = view.state.selection.main; + const hasSelection = sel.from !== sel.to; + const selectedText = hasSelection + ? view.state.sliceDoc(sel.from, sel.to) + : undefined; + dispatch("run", { selectedText }); return true; }, }, @@ -69,6 +73,20 @@ return editor?.state.doc.toString() ?? ""; } + export function getSelectedText(): string | undefined { + if (!editor) return undefined; + const sel = editor.state.selection.main; + if (sel.from === sel.to) return undefined; + return editor.state.sliceDoc(sel.from, sel.to); + } + + export function setContent(text: string) { + if (!editor) return; + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: text }, + }); + } + export function focus() { editor?.focus(); } diff --git a/web-common/src/features/query/QueryResultsInspector.svelte b/web-common/src/features/query/QueryResultsInspector.svelte deleted file mode 100644 index c0b6c35dd7a..00000000000 --- a/web-common/src/features/query/QueryResultsInspector.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - -
- {#if schema} -
- Rows - {formatInteger(rowCount)} - - Columns - {formatInteger(columnCount)} - - {#if executionTimeMs !== null} - Time - - {executionTimeMs < 1000 - ? `${executionTimeMs}ms` - : `${(executionTimeMs / 1000).toFixed(1)}s`} - - {/if} -
- -
- -
-
- - Result columns - -
- - {#if showColumns} -
-
    - {#each fields as field (field.name)} -
  • - - - {field.name} - - - {field.type?.code?.replace(/^CODE_/, "") ?? ""} - -
  • - {/each} -
-
- {/if} -
- {:else} -
- Run a query to see schema -
- {/if} -
-
- - diff --git a/web-common/src/features/query/QueryResultsTable.svelte b/web-common/src/features/query/QueryResultsTable.svelte index 8b4a692e18e..eb28cf75a56 100644 --- a/web-common/src/features/query/QueryResultsTable.svelte +++ b/web-common/src/features/query/QueryResultsTable.svelte @@ -28,13 +28,15 @@ } -{#if data && columns.length > 0} +{#if data && data.length > 0 && columns.length > 0} +{:else if data} +
+

No rows returned

+
{:else}
-

- Run a query to see results -

+

Run a query to see 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..9e39e5dbd09 --- /dev/null +++ b/web-common/src/features/query/QuerySchemaPanel.svelte @@ -0,0 +1,211 @@ + + + +
+ {#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} +

+ {tableError?.response?.data?.message || tableError?.message} +

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

No columns found

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

Query results

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

{formatTime(executionTimeMs)}

+ {/if} +
+ + {formatInteger(columnCount)} {columnCount === 1 + ? "column" + : "columns"} + +
+ +
+ +
+
+ + Result columns + +
+ + {#if showColumns} +
+
    + {#each fields as field (field.name)} +
  • + + + {field.name} + + + {prettyPrintType(field.type?.code)} + +
  • + {/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 index c3e86203e73..4e883de6dae 100644 --- a/web-common/src/features/query/QueryWorkspace.svelte +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -1,167 +1,186 @@ - -
-
-

Query

+
+ + + + -
- Connector - +
+
+ {#each $notebook.cells as cell (cell.id)} + -
- -
- Limit - -
- -
- {#if $queryConsole.isExecuting} -
- - Running... -
- {:else if $queryConsole.result} - - {formatInteger($rowCount)} {$rowCount === 1 ? "row" : "rows"} - {#if $queryConsole.executionTimeMs !== null} - in {$queryConsole.executionTimeMs < 1000 - ? `${$queryConsole.executionTimeMs}ms` - : `${($queryConsole.executionTimeMs / 1000).toFixed(1)}s`} - {/if} - - {/if} - - -
+ {/each} + +
-
- -
- - - - - {#if $tableVisible} - - {#if $queryConsole.isExecuting} -
- -
- {:else} - - {/if} -
- {/if}
- + -
+ diff --git a/web-common/src/features/query/query-store.ts b/web-common/src/features/query/query-store.ts index 7856b0926cd..c77151222f0 100644 --- a/web-common/src/features/query/query-store.ts +++ b/web-common/src/features/query/query-store.ts @@ -1,4 +1,6 @@ +import { browser } from "$app/environment"; import { writable, derived, get } from "svelte/store"; +import { debounce } from "@rilldata/web-common/lib/create-debouncer"; import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client"; import type { V1QueryResolverResponse, @@ -6,75 +8,217 @@ import type { V1QueryResolverResponseDataItem, } from "@rilldata/web-common/runtime-client"; -export interface QueryConsoleState { +export interface CellState { + id: string; sql: string; connector: string; - limit: number; + limit: number | undefined; // undefined = no limit isExecuting: boolean; result: V1QueryResolverResponse | null; error: string | null; executionTimeMs: number | null; + collapsed: boolean; +} + +export interface NotebookState { + cells: CellState[]; + focusedCellId: string | null; } const DEFAULT_LIMIT = 100; +const STORAGE_KEY = "rill:query-notebook"; + +interface PersistedCell { + id: string; + sql: string; + connector: string; + limit: number | undefined; + collapsed: boolean; +} + +function loadPersistedCells(): PersistedCell[] | null { + if (!browser) return null; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + const parsed = JSON.parse(stored); + if (Array.isArray(parsed) && parsed.length > 0) return parsed; + } catch { + // ignore corrupt data + } + return null; +} + +const debouncedSave = debounce((cells: CellState[]) => { + if (!browser) return; + const persisted: PersistedCell[] = cells.map((c) => ({ + id: c.id, + sql: c.sql, + connector: c.connector, + limit: c.limit, + collapsed: c.collapsed, + })); + localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted)); +}, 500); + +function hydrateCell(p: PersistedCell): CellState { + return { + ...p, + isExecuting: false, + result: null, + error: null, + executionTimeMs: null, + }; +} -function createQueryConsoleStore() { - const state = writable({ +function createDefaultCell(connector: string): CellState { + return { + id: crypto.randomUUID(), sql: "", - connector: "", + connector, limit: DEFAULT_LIMIT, isExecuting: false, result: null, error: null, executionTimeMs: null, + collapsed: 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) { + const persisted = loadPersistedCells(); + const initialCells = persisted + ? persisted.map(hydrateCell) + : [createDefaultCell(defaultConnector)]; + + const state = writable({ + cells: initialCells, + focusedCellId: initialCells[0]?.id ?? null, }); + // Auto-persist cell metadata on changes + state.subscribe(($s) => debouncedSave($s.cells)); + const { subscribe, update } = state; - function setSql(sql: string) { - update((s) => ({ ...s, sql })); + 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 setConnector(connector: string) { - update((s) => ({ ...s, connector })); + function toggleCellCollapsed(cellId: string) { + update((s) => + updateCell(s, cellId, (c) => ({ ...c, collapsed: !c.collapsed })), + ); } - function setLimit(limit: number) { - update((s) => ({ ...s, limit: Math.max(1, limit) })); + function setFocusedCell(cellId: string) { + update((s) => ({ ...s, focusedCellId: cellId })); } - async function executeQuery(instanceId: string) { + async function executeCellQuery( + cellId: string, + instanceId: string, + sqlOverride?: string, + ) { const current = get(state); - const sql = current.sql.trim(); - if (!sql) return; + const cell = current.cells.find((c) => c.id === cellId); + if (!cell) return; + + const sqlToRun = (sqlOverride ?? cell.sql).trim(); + if (!sqlToRun) return; update((s) => ({ - ...s, - isExecuting: true, - error: null, + ...updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: true, + error: null, + })), + focusedCellId: cellId, })); const startTime = performance.now(); try { - const response = await runtimeServiceQueryResolver(instanceId, { + const body: { + resolver: string; + resolverProperties: { sql: string; connector: string }; + limit?: number; + } = { resolver: "sql", resolverProperties: { - sql, - connector: current.connector, + sql: sqlToRun, + connector: cell.connector, }, - limit: current.limit, - }); + }; + + if (cell.limit !== undefined) { + body.limit = cell.limit; + } + const response = await runtimeServiceQueryResolver(instanceId, body); const elapsed = Math.round(performance.now() - startTime); - update((s) => ({ - ...s, - isExecuting: false, - result: response, - error: null, - executionTimeMs: elapsed, - })); + update((s) => + updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: false, + result: response, + error: null, + executionTimeMs: elapsed, + })), + ); } catch (err: unknown) { const elapsed = Math.round(performance.now() - startTime); const message = @@ -83,44 +227,56 @@ function createQueryConsoleStore() { (err as Error)?.message ?? "Query execution failed"; - update((s) => ({ - ...s, - isExecuting: false, - error: message, - executionTimeMs: elapsed, - })); + update((s) => + updateCell(s, cellId, (c) => ({ + ...c, + isExecuting: false, + error: message, + executionTimeMs: elapsed, + })), + ); } } - function reset() { - update((s) => ({ - ...s, - result: null, - error: null, - executionTimeMs: null, - })); - } + // 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; + }); - // Derived stores for convenience - const schema = derived(state, ($s) => $s.result?.schema ?? null); - const data = derived(state, ($s) => $s.result?.data ?? null); - const rowCount = derived(state, ($s) => $s.result?.data?.length ?? 0); + const focusedSchema = derived( + focusedCell, + ($c) => $c?.result?.schema ?? null, + ); + const focusedData = derived(focusedCell, ($c) => $c?.result?.data ?? null); + const focusedRowCount = derived( + focusedCell, + ($c) => $c?.result?.data?.length ?? 0, + ); + const focusedExecutionTimeMs = derived( + focusedCell, + ($c) => $c?.executionTimeMs ?? null, + ); return { subscribe, - setSql, - setConnector, - setLimit, - executeQuery, - reset, - schema, - data, - rowCount, + addCell, + removeCell, + setCellSql, + setCellConnector, + setCellLimit, + toggleCellCollapsed, + setFocusedCell, + executeCellQuery, + focusedSchema, + focusedData, + focusedRowCount, + focusedExecutionTimeMs, }; } -export type QueryConsoleStore = ReturnType; +export type NotebookStore = ReturnType; -export function createQueryConsole(): QueryConsoleStore { - return createQueryConsoleStore(); +export function createNotebook(defaultConnector: string): NotebookStore { + return createNotebookStore(defaultConnector); } From 40310b6e7f5a41ceefa43011b7a17ece03d8162a Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:00:24 -0500 Subject: [PATCH 03/34] cache schema, --- .../src/features/query/QueryCell.svelte | 2 +- .../features/query/QuerySchemaPanel.svelte | 144 +++++++++++------- web-common/src/features/query/query-store.ts | 32 +++- 3 files changed, 117 insertions(+), 61 deletions(-) diff --git a/web-common/src/features/query/QueryCell.svelte b/web-common/src/features/query/QueryCell.svelte index acbfc93ba3e..a6c6b88d474 100644 --- a/web-common/src/features/query/QueryCell.svelte +++ b/web-common/src/features/query/QueryCell.svelte @@ -33,7 +33,7 @@ $: schema = cell?.result?.schema ?? null; $: data = cell?.result?.data ?? null; - $: rowCount = cell?.result?.data?.length ?? 0; + $: rowCount = (cell?.result?.data?.length || cell?.lastRowCount) ?? 0; $: hasSql = (cell?.sql ?? "").trim().length > 0; function handleRun(e?: CustomEvent<{ selectedText?: string }>) { diff --git a/web-common/src/features/query/QuerySchemaPanel.svelte b/web-common/src/features/query/QuerySchemaPanel.svelte index 9e39e5dbd09..2a983c648f6 100644 --- a/web-common/src/features/query/QuerySchemaPanel.svelte +++ b/web-common/src/features/query/QuerySchemaPanel.svelte @@ -5,7 +5,9 @@ import InspectorHeaderGrid from "@rilldata/web-common/layout/inspector/InspectorHeaderGrid.svelte"; import { formatInteger } from "@rilldata/web-common/lib/formatters"; import type { V1StructType } from "@rilldata/web-common/runtime-client"; + import { createQueryServiceTableColumns } from "@rilldata/web-common/runtime-client"; import { useGetTable } from "@rilldata/web-common/features/connectors/selectors"; + import ColumnProfile from "@rilldata/web-common/features/column-profile/ColumnProfile.svelte"; import { runtime } from "../../runtime-client/runtime-store"; import { slide } from "svelte/transition"; import { LIST_SLIDE_DURATION } from "../../layout/config"; @@ -15,7 +17,7 @@ export let rowCount: number; export let executionTimeMs: number | null; - /** When set, shows table schema for the selected table instead of query results */ + /** When set, shows table profiling for the selected table instead of query results */ export let selectedTable: { connector: string; database: string; @@ -25,25 +27,48 @@ $: ({ instanceId } = $runtime); - // Fetch table schema when a table is selected from the data explorer - $: tableQuery = selectedTable - ? useGetTable( + // Try profiling API first (works in dev deployments) + $: profilingQuery = selectedTable + ? createQueryServiceTableColumns( instanceId, - selectedTable.connector, - selectedTable.database, - selectedTable.databaseSchema, selectedTable.objectName, + { + connector: selectedTable.connector, + database: selectedTable.database, + databaseSchema: selectedTable.databaseSchema, + }, ) : null; - $: tableColumns = $tableQuery?.data?.schema - ? Object.entries($tableQuery.data.schema).map(([name, type]) => ({ + $: profilingAvailable = + $profilingQuery?.isSuccess && + ($profilingQuery?.data?.profileColumns?.length ?? 0) > 0; + $: profilingError = $profilingQuery?.isError; + + // Fallback: use ConnectorService GetTable (always works) + $: fallbackQuery = + selectedTable && profilingError + ? useGetTable( + instanceId, + selectedTable.connector, + selectedTable.database, + selectedTable.databaseSchema, + selectedTable.objectName, + ) + : null; + + $: fallbackColumns = $fallbackQuery?.data?.schema + ? Object.entries($fallbackQuery.data.schema).map(([name, type]) => ({ name, type: type as string, })) : []; - $: tableLoading = $tableQuery?.isLoading ?? false; - $: tableError = $tableQuery?.error; + $: fallbackLoading = $fallbackQuery?.isLoading ?? false; + + // Column count: from profiling or fallback + $: tableColumnCount = profilingAvailable + ? ($profilingQuery?.data?.profileColumns?.length ?? 0) + : fallbackColumns.length; let showColumns = true; let showTableColumns = true; @@ -84,8 +109,8 @@ {/if} - {#if tableColumns.length > 0} - {formatInteger(tableColumns.length)} {tableColumns.length === 1 + {#if tableColumnCount > 0} + {formatInteger(tableColumnCount)} {tableColumnCount === 1 ? "column" : "columns"} {/if} @@ -94,49 +119,58 @@
-
-
- - Columns - -
- - {#if showTableColumns} -
- {#if tableLoading} -

Loading...

- {:else if tableError} -

- {tableError?.response?.data?.message || tableError?.message} -

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

No columns found

- {/if} + {#if profilingAvailable} + + + {:else if $profilingQuery?.isLoading} +

Loading...

+ {:else} + +
+
+ + Columns +
- {/if} -
+ + {#if showTableColumns} +
+ {#if fallbackLoading} +

Loading...

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

No columns found

+ {/if} +
+ {/if} +
+ {/if} {:else if schema} diff --git a/web-common/src/features/query/query-store.ts b/web-common/src/features/query/query-store.ts index c77151222f0..5d391435417 100644 --- a/web-common/src/features/query/query-store.ts +++ b/web-common/src/features/query/query-store.ts @@ -5,7 +5,6 @@ import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client import type { V1QueryResolverResponse, V1StructType, - V1QueryResolverResponseDataItem, } from "@rilldata/web-common/runtime-client"; export interface CellState { @@ -17,6 +16,7 @@ export interface CellState { result: V1QueryResolverResponse | null; error: string | null; executionTimeMs: number | null; + lastRowCount: number | null; // persisted row count from last execution collapsed: boolean; } @@ -34,6 +34,9 @@ interface PersistedCell { connector: string; limit: number | undefined; collapsed: boolean; + resultSchema: V1StructType | null; + resultRowCount: number | null; + executionTimeMs: number | null; } function loadPersistedCells(): PersistedCell[] | null { @@ -57,17 +60,29 @@ const debouncedSave = debounce((cells: CellState[]) => { 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(STORAGE_KEY, JSON.stringify(persisted)); }, 500); 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 { - ...p, + id: p.id, + sql: p.sql, + connector: p.connector, + limit: p.limit, + collapsed: p.collapsed, isExecuting: false, - result: null, + result: hasSchema + ? { schema: p.resultSchema!, data: [] } + : null, error: null, - executionTimeMs: null, + executionTimeMs: p.executionTimeMs ?? null, + lastRowCount: p.resultRowCount ?? null, }; } @@ -81,6 +96,7 @@ function createDefaultCell(connector: string): CellState { result: null, error: null, executionTimeMs: null, + lastRowCount: null, collapsed: false, }; } @@ -217,6 +233,7 @@ function createNotebookStore(defaultConnector: string) { result: response, error: null, executionTimeMs: elapsed, + lastRowCount: response.data?.length ?? 0, })), ); } catch (err: unknown) { @@ -251,7 +268,12 @@ function createNotebookStore(defaultConnector: string) { const focusedData = derived(focusedCell, ($c) => $c?.result?.data ?? null); const focusedRowCount = derived( focusedCell, - ($c) => $c?.result?.data?.length ?? 0, + ($c) => { + // Use live data length if available; fall back to persisted row count + const liveCount = $c?.result?.data?.length; + if (liveCount !== undefined && liveCount > 0) return liveCount; + return $c?.lastRowCount ?? 0; + }, ); const focusedExecutionTimeMs = derived( focusedCell, From 8aa64c3771d582988e85265389a052217ac84d02 Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:27:45 -0500 Subject: [PATCH 04/34] fix connector bug, simplify schema, edit messaging for nonlimits --- .../features/query/ConnectorSelector.svelte | 3 +- .../src/features/query/QueryCell.svelte | 3 +- .../features/query/QuerySchemaPanel.svelte | 144 +++++++----------- 3 files changed, 59 insertions(+), 91 deletions(-) diff --git a/web-common/src/features/query/ConnectorSelector.svelte b/web-common/src/features/query/ConnectorSelector.svelte index 052635a4fa6..52589c678a8 100644 --- a/web-common/src/features/query/ConnectorSelector.svelte +++ b/web-common/src/features/query/ConnectorSelector.svelte @@ -6,6 +6,7 @@ } from "@rilldata/web-common/runtime-client"; import { runtime } from "../../runtime-client/runtime-store"; + export let id: string = "connector-selector"; export let value: string = ""; export let onChange: (connector: string) => void = () => {}; @@ -50,7 +51,7 @@ (); @@ -41,7 +42,7 @@ $: schema = cell?.result?.schema ?? null; $: data = cell?.result?.data ?? null; - $: rowCount = (cell?.result?.data?.length || cell?.lastRowCount) ?? 0; + $: rowCount = cell?.result?.data?.length ?? cell?.lastRowCount ?? 0; $: hasExecuted = cell?.hasExecuted ?? false; $: hasSql = (cell?.sql ?? "").trim().length > 0; @@ -152,9 +153,7 @@ {formatInteger(rowCount)} {rowCount === 1 ? "row" : "rows"} {#if cell.executionTimeMs !== null} - in {cell.executionTimeMs < 1000 - ? `${cell.executionTimeMs}ms` - : `${(cell.executionTimeMs / 1000).toFixed(1)}s`} + in {formatExecutionTime(cell.executionTimeMs)} {/if} {/if} diff --git a/web-common/src/features/query/QueryEditor.svelte b/web-common/src/features/query/QueryEditor.svelte index 4ae60f600f5..cdae18e9d90 100644 --- a/web-common/src/features/query/QueryEditor.svelte +++ b/web-common/src/features/query/QueryEditor.svelte @@ -108,7 +108,7 @@ /> diff --git a/web-common/src/features/query/QuerySchemaPanel.svelte b/web-common/src/features/query/QuerySchemaPanel.svelte index a31ced9b48f..3ef2c547e0d 100644 --- a/web-common/src/features/query/QuerySchemaPanel.svelte +++ b/web-common/src/features/query/QuerySchemaPanel.svelte @@ -9,8 +9,8 @@ import { useRuntimeClient } from "../../runtime-client/v2"; import { slide } from "svelte/transition"; import { LIST_SLIDE_DURATION } from "../../layout/config"; - import { extractErrorMessage } from "./query-store"; - import { prettyPrintType } from "./query-utils"; + import { extractErrorMessage } from "@rilldata/web-common/lib/errors"; + import { formatExecutionTime, prettyPrintType } from "./query-utils"; export let filePath: string; export let schema: V1StructType | null; @@ -28,15 +28,14 @@ const runtimeClient = useRuntimeClient(); // Fetch table schema when a table is selected from the data explorer - $: tableQuery = selectedTable - ? useGetTable( - runtimeClient, - selectedTable.connector, - selectedTable.database, - selectedTable.databaseSchema, - selectedTable.objectName, - ) - : null; + // Always call useGetTable; it disables itself when table is empty + $: tableQuery = useGetTable( + runtimeClient, + selectedTable?.connector ?? "", + selectedTable?.database ?? "", + selectedTable?.databaseSchema ?? "", + selectedTable?.objectName ?? "", + ); $: tableColumns = $tableQuery?.data?.schema ? Object.entries($tableQuery.data.schema).map(([name, type]) => ({ @@ -52,10 +51,6 @@ $: fields = schema?.fields ?? []; $: columnCount = fields.length; - - function formatTime(ms: number): string { - return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; - } @@ -148,7 +143,7 @@ {#if executionTimeMs !== null} -

{formatTime(executionTimeMs)}

+

{formatExecutionTime(executionTimeMs)}

{/if}
diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte index 18fe74f6ec5..7825eeb60bb 100644 --- a/web-common/src/features/query/QueryWorkspace.svelte +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -23,9 +23,7 @@ const runtimeClient = useRuntimeClient(); // Get default OLAP connector for new cells - $: instanceQuery = createRuntimeServiceGetInstance(runtimeClient, { - sensitive: true, - }); + $: instanceQuery = createRuntimeServiceGetInstance(runtimeClient, {}); $: olapConnector = $instanceQuery.data?.instance?.olapConnector ?? ""; // Create notebook store once we have the default connector @@ -47,14 +45,6 @@ // Refs to cell editors for programmatic content setting let cellRefs: Record = {}; - // Clean up stale refs when cells are removed - $: if (notebook) { - const cellIds = new Set(get(notebook).cells.map((c) => c.id)); - for (const id of Object.keys(cellRefs)) { - if (!cellIds.has(id)) delete cellRefs[id]; - } - } - // Data explorer sidebar const explorerStore = new ConnectorExplorerStore( { @@ -109,6 +99,14 @@ $: nb = notebook ?? EMPTY_NOTEBOOK; $: cells = $nb.cells; + // Clean up stale refs when cells change + $: { + const cellIds = new Set(cells.map((c) => c.id)); + for (const id of Object.keys(cellRefs)) { + if (!cellIds.has(id)) delete cellRefs[id]; + } + } + // Derived stores for the focused cell (forwarded to inspector) $: focusedSchemaStore = notebook?.focusedSchema ?? NULL_READABLE; $: focusedRowCountStore = notebook?.focusedRowCount ?? ZERO_READABLE; diff --git a/web-common/src/features/query/query-store.spec.ts b/web-common/src/features/query/query-store.spec.ts index 10e35191d48..28c633b127a 100644 --- a/web-common/src/features/query/query-store.spec.ts +++ b/web-common/src/features/query/query-store.spec.ts @@ -427,6 +427,7 @@ describe("createNotebook", () => { sql: "SELECT override", }), }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); @@ -453,6 +454,7 @@ describe("createNotebook", () => { }, limit: 25, }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); @@ -498,28 +500,44 @@ describe("createNotebook", () => { expect(runtimeServiceQueryResolver).not.toHaveBeenCalled(); }); - it("skips execution when the cell is already executing", async () => { + 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 resolveQuery!: (value: unknown) => void; - vi.mocked(runtimeServiceQueryResolver).mockReturnValue( - new Promise((resolve) => { - resolveQuery = resolve; - }), - ); + let resolveFirst!: (value: unknown) => void; + let rejectFirst!: (reason: unknown) => void; + vi.mocked(runtimeServiceQueryResolver) + .mockReturnValueOnce( + new Promise((resolve, reject) => { + resolveFirst = resolve; + 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 while first is in-flight should be a no-op - await store.executeCellQuery(cellId, MOCK_CLIENT); - expect(runtimeServiceQueryResolver).toHaveBeenCalledTimes(1); + // Second call aborts the first and starts a new execution + const second = store.executeCellQuery(cellId, MOCK_CLIENT); - resolveQuery({ schema: null, data: [] }); + // 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); }); }); diff --git a/web-common/src/features/query/query-store.ts b/web-common/src/features/query/query-store.ts index 20392d1b648..02da1c1a9c8 100644 --- a/web-common/src/features/query/query-store.ts +++ b/web-common/src/features/query/query-store.ts @@ -1,6 +1,7 @@ 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 { @@ -60,17 +61,21 @@ function loadPersistedCells(projectId: string): PersistedCell[] | null { function saveToLocalStorage(projectId: string, cells: CellState[]) { if (!browser) return; - 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)); + 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 { @@ -107,25 +112,6 @@ function createDefaultCell(connector: string): CellState { }; } -/** Extracts a human-readable message from an API or runtime error */ -export function extractErrorMessage(err: unknown): string { - if (err && typeof err === "object") { - // Axios-style error with response.data.message - if ("response" in err) { - const resp = (err as Record).response; - if (resp && typeof resp === "object" && "data" in resp) { - const data = (resp as Record).data; - if (data && typeof data === "object" && "message" in data) { - const msg = (data as Record).message; - if (typeof msg === "string" && msg) return msg; - } - } - } - if (err instanceof Error) return err.message; - } - return "Query execution failed"; -} - function updateCell( state: NotebookState, cellId: string, @@ -215,6 +201,9 @@ function createNotebookStore(defaultConnector: string, projectId: string) { update((s) => ({ ...s, focusedCellId: cellId })); } + // Per-cell abort controllers for query cancellation + const abortControllers = new Map(); + async function executeCellQuery( cellId: string, client: RuntimeClient, @@ -222,11 +211,16 @@ function createNotebookStore(defaultConnector: string, projectId: string) { ) { const current = get(state); const cell = current.cells.find((c) => c.id === cellId); - if (!cell || cell.isExecuting) return; + 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, @@ -239,23 +233,18 @@ function createNotebookStore(defaultConnector: string, projectId: string) { const startTime = performance.now(); try { - const body: { - resolver: string; - resolverProperties: { sql: string; connector: string }; - limit?: number; - } = { - resolver: "sql", - resolverProperties: { - sql: sqlToRun, - connector: cell.connector, - }, - }; - - if (cell.limit !== undefined) { - body.limit = cell.limit; - } - - const response = await runtimeServiceQueryResolver(client, body); + 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) => @@ -270,6 +259,9 @@ function createNotebookStore(defaultConnector: string, projectId: string) { })), ); } 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); @@ -282,6 +274,8 @@ function createNotebookStore(defaultConnector: string, projectId: string) { hasExecuted: true, })), ); + } finally { + abortControllers.delete(cellId); } } @@ -309,6 +303,10 @@ function createNotebookStore(defaultConnector: string, projectId: string) { function destroy() { unsubPersist?.(); + for (const controller of abortControllers.values()) { + controller.abort(); + } + abortControllers.clear(); } return { diff --git a/web-common/src/features/query/query-utils.spec.ts b/web-common/src/features/query/query-utils.spec.ts index b958939910c..82d893a3f44 100644 --- a/web-common/src/features/query/query-utils.spec.ts +++ b/web-common/src/features/query/query-utils.spec.ts @@ -1,4 +1,7 @@ -import { prettyPrintType } from "@rilldata/web-common/features/query/query-utils"; +import { + formatExecutionTime, + prettyPrintType, +} from "@rilldata/web-common/features/query/query-utils"; import { describe, expect, it } from "vitest"; describe("prettyPrintType", () => { @@ -53,3 +56,17 @@ describe("prettyPrintType", () => { 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 index 61459718a12..cabdc4dd993 100644 --- a/web-common/src/features/query/query-utils.ts +++ b/web-common/src/features/query/query-utils.ts @@ -4,3 +4,8 @@ export function prettyPrintType(code: string | undefined): string { 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`; +} From 4953ec6e8e2601a26242d221f41b6522af6ba045 Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:15:37 -0500 Subject: [PATCH 27/34] cd/cd fix --- web-common/src/features/query/QueryWorkspace.svelte | 6 ++++-- web-common/src/features/query/query-store.spec.ts | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte index 7825eeb60bb..a5e0ae59400 100644 --- a/web-common/src/features/query/QueryWorkspace.svelte +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -22,8 +22,10 @@ const runtimeClient = useRuntimeClient(); - // Get default OLAP connector for new cells - $: instanceQuery = createRuntimeServiceGetInstance(runtimeClient, {}); + // Get default OLAP connector for new cells (sensitive: true required to include olapConnector) + $: instanceQuery = createRuntimeServiceGetInstance(runtimeClient, { + sensitive: true, + }); $: olapConnector = $instanceQuery.data?.instance?.olapConnector ?? ""; // Create notebook store once we have the default connector diff --git a/web-common/src/features/query/query-store.spec.ts b/web-common/src/features/query/query-store.spec.ts index 28c633b127a..638a825f503 100644 --- a/web-common/src/features/query/query-store.spec.ts +++ b/web-common/src/features/query/query-store.spec.ts @@ -505,12 +505,10 @@ describe("createNotebook", () => { const cellId = getState(store).cells[0].id; store.setCellSql(cellId, "SELECT 1"); - let resolveFirst!: (value: unknown) => void; let rejectFirst!: (reason: unknown) => void; vi.mocked(runtimeServiceQueryResolver) .mockReturnValueOnce( - new Promise((resolve, reject) => { - resolveFirst = resolve; + new Promise((_resolve, reject) => { rejectFirst = reject; }), ) From 49bbd3748b38b8ffd79e1077fc634f31ba752cc9 Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:25:53 -0500 Subject: [PATCH 28/34] code fixes --- runtime/feature_flags.go | 2 ++ web-common/src/features/feature-flags.ts | 2 +- .../src/features/query/QueryCell.svelte | 36 ++++++++++--------- .../src/features/query/QueryEditor.svelte | 20 +---------- web-common/src/features/query/query-store.ts | 14 ++++++++ 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/runtime/feature_flags.go b/runtime/feature_flags.go index 7448dfc3eb3..5583a1b8c10 100644 --- a/runtime/feature_flags.go +++ b/runtime/feature_flags.go @@ -58,6 +58,8 @@ 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 + "query_editor": "true", // Controls if the dashboard state is persisted when navigating to a different dashboard. "sticky_dashboard_state": "false", } diff --git a/web-common/src/features/feature-flags.ts b/web-common/src/features/feature-flags.ts index 6f379b4f010..9880af4c74e 100644 --- a/web-common/src/features/feature-flags.ts +++ b/web-common/src/features/feature-flags.ts @@ -63,7 +63,7 @@ class FeatureFlags { dashboardChat = new FeatureFlag("user", false); developerChat = new FeatureFlag("user", false); deploy = new FeatureFlag("user", true); - queryEditor = new FeatureFlag("user", true); + queryEditor = new FeatureFlag("user", true); //TODO Change to False when ready stickyDashboardState = new FeatureFlag("user", false); private flagsUnsub?: () => void; diff --git a/web-common/src/features/query/QueryCell.svelte b/web-common/src/features/query/QueryCell.svelte index 09ba3f6c143..1d890ec62b0 100644 --- a/web-common/src/features/query/QueryCell.svelte +++ b/web-common/src/features/query/QueryCell.svelte @@ -46,19 +46,16 @@ $: hasExecuted = cell?.hasExecuted ?? false; $: hasSql = (cell?.sql ?? "").trim().length > 0; - function runQuery(sqlOverride?: string) { + function handleRunButton() { if (!cell || cell.isExecuting) return; notebook.setFocusedCell(cellId); - notebook.executeCellQuery(cellId, runtimeClient, sqlOverride); + const selected = editorRef?.getSelectedText(); + notebook.executeCellQuery(cellId, runtimeClient, selected); dispatch("run"); } - function handleRun(e: CustomEvent<{ selectedText?: string }>) { - runQuery(e.detail?.selectedText); - } - - function handleRunButton() { - runQuery(editorRef?.getSelectedText()); + function handleStopButton() { + notebook.cancelCellQuery(cellId); } function handleChange(e: CustomEvent) { @@ -158,14 +155,20 @@ {/if} - + {#if cell.isExecuting} + + {:else} + + {/if} {#if canDelete}
- {:else if cell.result} + {:else if cell.result && (hasExecuted || cell.lastRowCount)} {formatInteger(rowCount)} {rowCount === 1 ? "row" : "rows"} diff --git a/web-common/src/features/query/QuerySchemaPanel.svelte b/web-common/src/features/query/QuerySchemaPanel.svelte index 3ef2c547e0d..d2b6e5b9380 100644 --- a/web-common/src/features/query/QuerySchemaPanel.svelte +++ b/web-common/src/features/query/QuerySchemaPanel.svelte @@ -12,6 +12,11 @@ import { extractErrorMessage } from "@rilldata/web-common/lib/errors"; import { formatExecutionTime, prettyPrintType } from "./query-utils"; + interface ColumnEntry { + name: string; + type: string; + } + export let filePath: string; export let schema: V1StructType | null; export let rowCount: number; @@ -37,12 +42,7 @@ selectedTable?.objectName ?? "", ); - $: tableColumns = $tableQuery?.data?.schema - ? Object.entries($tableQuery.data.schema).map(([name, type]) => ({ - name, - type: type as string, - })) - : []; + $: tableColumns = toColumnEntries($tableQuery?.data?.schema); $: tableLoading = $tableQuery?.isLoading ?? false; $: tableError = $tableQuery?.error; @@ -50,7 +50,21 @@ let showTableColumns = true; $: fields = schema?.fields ?? []; + $: resultColumns = fields.map((f) => ({ + name: f.name ?? "", + type: prettyPrintType(f.type?.code), + })); $: columnCount = fields.length; + + function toColumnEntries( + tableSchema: Record | undefined | null, + ): ColumnEntry[] { + if (!tableSchema) return []; + return Object.entries(tableSchema).map(([name, type]) => ({ + name, + type: prettyPrintType(type as string), + })); + } @@ -106,10 +120,7 @@
    {#each tableColumns as column (column.name)}
  • - + - {prettyPrintType(column.type)} + {column.type}
  • {/each} @@ -167,19 +178,16 @@ {#if showColumns}
      - {#each fields as field (field.name)} + {#each resultColumns as column (column.name)}
    • - - - {field.name} + + + {column.name} - {prettyPrintType(field.type?.code)} + {column.type}
    • {/each} diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte index a5e0ae59400..87a55942bd8 100644 --- a/web-common/src/features/query/QueryWorkspace.svelte +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -55,35 +55,37 @@ allowShowSchema: true, allowSelectTable: false, }, - // onToggleItem: show ColumnProfile when a table is expanded - (connector, database, schema, table) => { - if (!table) { - selectedTable = null; - return; - } - selectedTable = { - connector, - database: database ?? "", - databaseSchema: schema ?? "", - objectName: table, - }; - }, - // onInsertTable: "+" button inserts SELECT * FROM table at cursor in focused cell - (driver, _connector, database, schema, table) => { - if (!notebook) return; - const state = get(notebook); - const focusedId = state.focusedCellId ?? state.cells[0]?.id; - if (!focusedId) return; - - const tableRef = makeSufficientlyQualifiedTableName( - driver, - database, - schema, - table, - ); - const sql = `SELECT * FROM ${tableRef}`; - - cellRefs[focusedId]?.insertAtCursor(sql); + { + // Show table schema in right panel when a table is expanded + onToggleItem: (connector, database, schema, table) => { + if (!table) { + selectedTable = null; + return; + } + selectedTable = { + connector, + database: database ?? "", + databaseSchema: schema ?? "", + objectName: table, + }; + }, + // "+" button inserts SELECT * FROM table at cursor in focused cell + onInsertTable: (driver, _connector, database, schema, table) => { + if (!notebook) return; + const state = get(notebook); + const focusedId = state.focusedCellId ?? state.cells[0]?.id; + if (!focusedId) return; + + const tableRef = makeSufficientlyQualifiedTableName( + driver, + database, + schema, + table, + ); + const sql = `SELECT * FROM ${tableRef}`; + + cellRefs[focusedId]?.insertAtCursor(sql); + }, }, ); diff --git a/web-common/src/features/query/query-store.ts b/web-common/src/features/query/query-store.ts index f2ae6482d5f..0a609d923e1 100644 --- a/web-common/src/features/query/query-store.ts +++ b/web-common/src/features/query/query-store.ts @@ -289,7 +289,6 @@ function createNotebookStore(defaultConnector: string, projectId: string) { focusedCell, ($c) => $c?.result?.schema ?? null, ); - const focusedData = derived(focusedCell, ($c) => $c?.result?.data ?? null); const focusedRowCount = derived(focusedCell, ($c) => { // Use live data length if available; fall back to persisted row count const liveCount = $c?.result?.data?.length; @@ -335,7 +334,6 @@ function createNotebookStore(defaultConnector: string, projectId: string) { executeCellQuery, cancelCellQuery, focusedSchema, - focusedData, focusedRowCount, focusedExecutionTimeMs, }; From 27f6378c0af6e696ddb355bc8ef042520d35255c Mon Sep 17 00:00:00 2001 From: royendo <67675319+royendo@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:02:26 -0400 Subject: [PATCH 32/34] fix error UI and add download output data option --- .../src/features/query/QueryCell.svelte | 47 ++++++++++++++- web-common/src/features/query/query-export.ts | 59 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 web-common/src/features/query/query-export.ts diff --git a/web-common/src/features/query/QueryCell.svelte b/web-common/src/features/query/QueryCell.svelte index 78b9aed8eb9..0b105bff283 100644 --- a/web-common/src/features/query/QueryCell.svelte +++ b/web-common/src/features/query/QueryCell.svelte @@ -1,6 +1,9 @@ -
      - +
      +
      + +
      + {#if $dashboardChat && $chatOpen} + + {/if}
      diff --git a/web-common/src/features/query/QueryCell.svelte b/web-common/src/features/query/QueryCell.svelte index 50224295011..54398f7124e 100644 --- a/web-common/src/features/query/QueryCell.svelte +++ b/web-common/src/features/query/QueryCell.svelte @@ -230,7 +230,9 @@
      {#if cell.error} -
      + + +
      {cell.error}
      @@ -334,7 +336,7 @@ .cell-error { @apply flex items-center gap-x-2 px-3 py-2 text-sm text-fg-primary; @apply border-l-4 border-destructive bg-destructive/15; - @apply max-h-40 overflow-auto; + @apply max-h-40 overflow-auto select-text; } .cell-results { diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte index 87a55942bd8..8b0a51523e3 100644 --- a/web-common/src/features/query/QueryWorkspace.svelte +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -1,4 +1,8 @@