From 85f05a0801537a6a3c480d970175b62f3a6e2b02 Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:09:11 +0530 Subject: [PATCH] feat: SQL console for OLAP tables --- runtime/feature_flags.go | 2 + runtime/feature_flags_test.go | 4 + .../src/features/projects/ProjectTabs.svelte | 12 +- .../[project]/-/query/+page.svelte | 66 ++++ .../[organization]/[project]/-/query/+page.ts | 12 + .../explorer/ConnectorExplorer.svelte | 17 +- web-common/src/features/feature-flags.ts | 1 + .../src/features/query/QueryEditor.svelte | 143 ++++++++ .../features/query/QueryResultsTable.svelte | 48 +++ .../src/features/query/QueryWorkspace.svelte | 334 ++++++++++++++++++ .../src/features/query/query-store.spec.ts | 176 +++++++++ web-common/src/features/query/query-store.ts | 116 ++++++ .../src/features/query/query-utils.spec.ts | 68 ++++ web-common/src/features/query/query-utils.ts | 181 ++++++++++ web-common/src/lib/i18n/messages/en.json | 2 +- web-common/src/lib/i18n/messages/es.json | 2 +- 16 files changed, 1170 insertions(+), 14 deletions(-) create mode 100644 web-admin/src/routes/[organization]/[project]/-/query/+page.svelte create mode 100644 web-admin/src/routes/[organization]/[project]/-/query/+page.ts create mode 100644 web-common/src/features/query/QueryEditor.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.spec.ts create mode 100644 web-common/src/features/query/query-store.ts create mode 100644 web-common/src/features/query/query-utils.spec.ts create mode 100644 web-common/src/features/query/query-utils.ts diff --git a/runtime/feature_flags.go b/runtime/feature_flags.go index b153d1fc07db..76f6d5afd5b2 100644 --- a/runtime/feature_flags.go +++ b/runtime/feature_flags.go @@ -68,6 +68,8 @@ var defaultFeatureFlags = map[string]string{ "custom_charts": "false", // Controls visibility of the personal canvas feature (per-user owner-only canvases stored as virtual files) "personal_canvases": "false", + // Controls visibility of the SQL Console + "sql_console": "false", } // ResolveFeatureFlags resolves feature flags for the given instance and the provided user attributes. diff --git a/runtime/feature_flags_test.go b/runtime/feature_flags_test.go index 33d2f38ff074..b6625a5e8262 100644 --- a/runtime/feature_flags_test.go +++ b/runtime/feature_flags_test.go @@ -46,6 +46,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "cloudEditing": false, "customCharts": false, "personalCanvases": false, + "sqlConsole": false, "disablePersistentDashboardState": false, }, }, @@ -74,6 +75,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "cloudEditing": false, "customCharts": false, "personalCanvases": false, + "sqlConsole": false, "disablePersistentDashboardState": false, }, }, @@ -102,6 +104,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "cloudEditing": false, "customCharts": false, "personalCanvases": false, + "sqlConsole": false, "disablePersistentDashboardState": false, }, }, @@ -130,6 +133,7 @@ func Test_ResolveFeatureFlags(t *testing.T) { "cloudEditing": false, "customCharts": false, "personalCanvases": false, + "sqlConsole": false, "disablePersistentDashboardState": false, }, }, diff --git a/web-admin/src/features/projects/ProjectTabs.svelte b/web-admin/src/features/projects/ProjectTabs.svelte index 5709f1a91085..52ef367bfaba 100644 --- a/web-admin/src/features/projects/ProjectTabs.svelte +++ b/web-admin/src/features/projects/ProjectTabs.svelte @@ -15,7 +15,7 @@ export let pathname: string; export let branchPrefix: string = ""; - const { chat, reports, alerts } = featureFlags; + const { chat, reports, alerts, sqlConsole } = featureFlags; $: tabs = [ { @@ -33,11 +33,6 @@ label: m.nav_tab_dashboards(), hasPermission: true, }, - { - route: `/${organization}/${project}${branchPrefix}/-/query`, - label: m.nav_tab_query(), - hasPermission: false, - }, { route: `/${organization}/${project}${branchPrefix}/-/reports`, label: m.nav_tab_reports(), @@ -53,6 +48,11 @@ label: m.nav_tab_status(), hasPermission: projectPermissions.manageProject, }, + { + route: `/${organization}/${project}${branchPrefix}/-/query`, + label: m.nav_tab_query(), + hasPermission: $sqlConsole && projectPermissions.manageProject, + }, { route: `/${organization}/${project}${branchPrefix}/-/settings`, label: m.nav_tab_settings(), 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 000000000000..bc5eeba9e522 --- /dev/null +++ b/web-admin/src/routes/[organization]/[project]/-/query/+page.svelte @@ -0,0 +1,66 @@ + + + + SQL Console · {project} · Rill + + +
+ {#if isLoading} +
+ + Loading SQL Console +
+ {:else if instanceError} + + {:else if !sqlConsoleEnabled} + + {:else if olapConnector} + + {:else} +
+ No project OLAP connector is available. +
+ {/if} +
diff --git a/web-admin/src/routes/[organization]/[project]/-/query/+page.ts b/web-admin/src/routes/[organization]/[project]/-/query/+page.ts new file mode 100644 index 000000000000..d0c7e53dc2a6 --- /dev/null +++ b/web-admin/src/routes/[organization]/[project]/-/query/+page.ts @@ -0,0 +1,12 @@ +import { redirect } from "@sveltejs/kit"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ + parent, + params: { organization, project }, +}) => { + const { projectPermissions } = await parent(); + if (!projectPermissions?.manageProject) { + throw redirect(307, `/${organization}/${project}`); + } +}; diff --git a/web-common/src/features/connectors/explorer/ConnectorExplorer.svelte b/web-common/src/features/connectors/explorer/ConnectorExplorer.svelte index 4460af61f859..f5226307d77a 100644 --- a/web-common/src/features/connectors/explorer/ConnectorExplorer.svelte +++ b/web-common/src/features/connectors/explorer/ConnectorExplorer.svelte @@ -8,6 +8,8 @@ export let store: ConnectorExplorerStore; export let olapOnly: boolean = false; + /** When set, only display the connector with this name. */ + export let onlyConnector: string = ""; /** Auto-expand this connector when the list first renders */ export let defaultExpanded: string = ""; @@ -15,12 +17,15 @@ $: connectors = getAnalyzedConnectors(client, olapOnly); $: ({ data, error, isLoading } = $connectors); + $: displayedConnectors = onlyConnector + ? data?.connectors?.filter((connector) => connector.name === onlyConnector) + : data?.connectors; // When defaultExpanded is set, pre-seed the store so only that connector // starts expanded and others start collapsed. let hasAutoExpanded = false; - $: if (defaultExpanded && data?.connectors && !hasAutoExpanded) { - for (const c of data.connectors) { + $: if (defaultExpanded && displayedConnectors && !hasAutoExpanded) { + for (const c of displayedConnectors) { if (!c.name) continue; // Pre-seed each connector before ConnectorEntry renders. // This prevents getDefaultState from expanding all connectors. @@ -44,19 +49,19 @@ {error.message} - {:else if data?.connectors} - {#if data.connectors.length === 0} + {:else if displayedConnectors} + {#if displayedConnectors.length === 0} No data found. Add data to get started! {:else}
    - {#each data.connectors as connector (connector.name)} + {#each displayedConnectors as connector (connector.name)} {/each}
{/if} {:else if isLoading}
- {#each [0.6, 0.75, 0.5] as width} + {#each [0.6, 0.75, 0.5] as width (width)}
void; diff --git a/web-common/src/features/query/QueryEditor.svelte b/web-common/src/features/query/QueryEditor.svelte new file mode 100644 index 000000000000..25372ecc42a8 --- /dev/null +++ b/web-common/src/features/query/QueryEditor.svelte @@ -0,0 +1,143 @@ + + +
+ + diff --git a/web-common/src/features/query/QueryResultsTable.svelte b/web-common/src/features/query/QueryResultsTable.svelte new file mode 100644 index 000000000000..3476bb3070fb --- /dev/null +++ b/web-common/src/features/query/QueryResultsTable.svelte @@ -0,0 +1,48 @@ + + +{#if rows.length > 0 && columns.length > 0} + +{:else if error} +
+

Query failed. Review the error above and try again.

+
+{:else if hasExecuted} +
+

No rows returned

+
+{:else} +
+

Run a query to view results

+
+{/if} + + diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte new file mode 100644 index 000000000000..1b6e76c766b0 --- /dev/null +++ b/web-common/src/features/query/QueryWorkspace.svelte @@ -0,0 +1,334 @@ + + +
+ {#if worksheet} +
+ {#if showTables} + + + + {/if} + +
+
+
+ {#if !showTables} + + {/if} +

SQL Console

+ Read-only +
+ +
+ {#if isExecuting} + + + Running + + + {:else} + ⌘/Ctrl + Enter + + {/if} +
+
+ +
+
+ worksheet?.setSql(sql)} + onrun={execute} + /> +
+ + {#if worksheetError} + + {/if} +
+ +
+ +
+ +
+
+
+

Results

+ {#if lastExecution} + + {lastExecution.startLine === lastExecution.endLine + ? `Line ${lastExecution.startLine}` + : `Lines ${lastExecution.startLine}–${lastExecution.endLine}`} + + {/if} +
+ +
+ {#if isExecuting} + + + Running + + {:else if result} + + {#if (result.data?.length ?? 0) === SQL_CONSOLE_ROW_LIMIT} + Showing first {formatInteger(SQL_CONSOLE_ROW_LIMIT)} rows + {:else} + {formatInteger(result.data?.length ?? 0)} + {(result.data?.length ?? 0) === 1 ? "row" : "rows"} + {/if} + {#if executionTimeMs !== null} + · {formatExecutionTime(executionTimeMs)} + {/if} + + {/if} +
+
+ +
+ +
+
+
+
+ {/if} +
+ + 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 000000000000..c50ba76b9da8 --- /dev/null +++ b/web-common/src/features/query/query-store.spec.ts @@ -0,0 +1,176 @@ +import { get } from "svelte/store"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { V1QueryResolverResponse } from "@rilldata/web-common/runtime-client"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import type { SqlExecution } from "./query-utils"; + +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 { + clearSqlWorksheetCache, + createSqlWorksheet, + SQL_CONSOLE_ROW_LIMIT, +} from "./query-store"; + +const client = { instanceId: "instance" } as RuntimeClient; +const execution: SqlExecution = { + sql: "SELECT 42", + from: 8, + to: 17, + startLine: 2, + endLine: 2, +}; + +describe("createSqlWorksheet", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + clearSqlWorksheetCache(); + }); + + it("starts with an empty worksheet", () => { + const worksheet = createSqlWorksheet("duckdb", "acme/analytics"); + + expect(get(worksheet)).toEqual({ + sql: "", + isExecuting: false, + hasExecuted: false, + result: null, + error: null, + executionTimeMs: null, + lastExecution: null, + }); + }); + + it("restores worksheet text from the in-memory cache", () => { + const worksheet = createSqlWorksheet("duckdb", "acme/analytics"); + worksheet.setSql("SELECT * FROM events"); + worksheet.destroy(); + + const restored = createSqlWorksheet("duckdb", "acme/analytics"); + + expect(get(restored).sql).toBe("SELECT * FROM events"); + expect(localStorage.length).toBe(0); + }); + + it("separates cached worksheets by project and branch", () => { + const production = createSqlWorksheet("duckdb", "acme/analytics"); + const branch = createSqlWorksheet("duckdb", "acme/analytics@new-metrics"); + production.setSql("SELECT 'production'"); + branch.setSql("SELECT 'branch'"); + + expect(get(createSqlWorksheet("duckdb", "acme/analytics")).sql).toBe( + "SELECT 'production'", + ); + expect( + get(createSqlWorksheet("duckdb", "acme/analytics@new-metrics")).sql, + ).toBe("SELECT 'branch'"); + }); + + it("limits worksheet results to 1,000 rows", async () => { + const result: V1QueryResolverResponse = { + schema: { fields: [{ name: "answer" }] }, + data: [{ answer: 42 }], + }; + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue(result); + const worksheet = createSqlWorksheet("clickhouse", "acme/analytics"); + + await worksheet.execute(client, execution); + + const [calledClient, request, options] = vi.mocked( + runtimeServiceQueryResolver, + ).mock.calls[0]; + expect(calledClient).toBe(client); + expect(request).toMatchObject({ + resolver: "sql", + resolverProperties: { + sql: "SELECT 42", + connector: "clickhouse", + }, + }); + expect(request.limit).toBe(SQL_CONSOLE_ROW_LIMIT); + expect(options?.signal).toBeInstanceOf(AbortSignal); + expect(get(worksheet)).toMatchObject({ + result, + hasExecuted: true, + isExecuting: false, + error: null, + lastExecution: execution, + }); + }); + + it("does not classify SQL on the client", async () => { + vi.mocked(runtimeServiceQueryResolver).mockRejectedValue( + new Error("statement rejected by runtime"), + ); + const worksheet = createSqlWorksheet("duckdb", "acme/analytics"); + + await worksheet.execute(client, { + ...execution, + sql: "DELETE FROM events", + }); + + expect(runtimeServiceQueryResolver).toHaveBeenCalledOnce(); + expect(get(worksheet).error).toBe("statement rejected by runtime"); + }); + + it("retains the last successful result when a later run fails", async () => { + const result: V1QueryResolverResponse = { data: [{ value: 1 }] }; + vi.mocked(runtimeServiceQueryResolver) + .mockResolvedValueOnce(result) + .mockRejectedValueOnce(new Error("query failed")); + const worksheet = createSqlWorksheet("duckdb", "acme/analytics"); + + await worksheet.execute(client, execution); + const executionTimeMs = get(worksheet).executionTimeMs; + await worksheet.execute(client, { ...execution, sql: "SELECT broken" }); + + // The retained result keeps its own execution time; the failed run does not + // overwrite it, so the results header stays consistent with the table. + expect(get(worksheet)).toMatchObject({ + result, + error: "query failed", + lastExecution: execution, + executionTimeMs, + }); + }); + + it("cancels an in-flight query", async () => { + vi.mocked(runtimeServiceQueryResolver).mockImplementation( + (_client, _request, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }), + ); + const worksheet = createSqlWorksheet("duckdb", "acme/analytics"); + + const request = worksheet.execute(client, execution); + expect(get(worksheet).isExecuting).toBe(true); + worksheet.cancel(); + await request; + + expect(get(worksheet).isExecuting).toBe(false); + expect(get(worksheet).error).toBeNull(); + }); + + it("does not cache query results across navigation", async () => { + vi.mocked(runtimeServiceQueryResolver).mockResolvedValue({ + data: [{ answer: 42 }], + }); + const worksheet = createSqlWorksheet("duckdb", "acme/analytics"); + worksheet.setSql("SELECT 42"); + await worksheet.execute(client, execution); + worksheet.destroy(); + + const restored = createSqlWorksheet("duckdb", "acme/analytics"); + + expect(get(restored).sql).toBe("SELECT 42"); + expect(get(restored).result).toBeNull(); + expect(get(restored).hasExecuted).toBe(false); + }); +}); 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 000000000000..e6b6e7a17018 --- /dev/null +++ b/web-common/src/features/query/query-store.ts @@ -0,0 +1,116 @@ +import type { PartialMessage, Struct } from "@bufbuild/protobuf"; +import { browser } from "$app/environment"; +import { get, writable } from "svelte/store"; +import { extractErrorMessage } from "@rilldata/web-common/lib/errors"; +import type { V1QueryResolverResponse } from "@rilldata/web-common/runtime-client"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client/v2/gen/runtime-service"; +import type { SqlExecution } from "./query-utils"; + +export interface SqlWorksheetState { + sql: string; + isExecuting: boolean; + hasExecuted: boolean; + result: V1QueryResolverResponse | null; + error: string | null; + executionTimeMs: number | null; + lastExecution: SqlExecution | null; +} + +export const SQL_CONSOLE_ROW_LIMIT = 1_000; + +const worksheetCache = new Map(); + +export function createSqlWorksheet(connector: string, cacheKey: string) { + const state = writable({ + sql: browser ? (worksheetCache.get(cacheKey) ?? "") : "", + isExecuting: false, + hasExecuted: false, + result: null, + error: null, + executionTimeMs: null, + lastExecution: null, + }); + let abortController: AbortController | null = null; + + function setSql(sql: string) { + state.update(($state) => ({ ...$state, sql })); + if (browser) worksheetCache.set(cacheKey, sql); + } + + async function execute(client: RuntimeClient, execution: SqlExecution) { + if (get(state).isExecuting || !execution.sql.trim()) return; + + abortController = new AbortController(); + state.update(($state) => ({ + ...$state, + isExecuting: true, + error: null, + })); + const startedAt = performance.now(); + const currentController = abortController; + + try { + const result = await runtimeServiceQueryResolver( + client, + { + resolver: "sql", + resolverProperties: { + sql: execution.sql, + connector, + } as unknown as PartialMessage, + limit: SQL_CONSOLE_ROW_LIMIT, + }, + { signal: currentController.signal }, + ); + + state.update(($state) => ({ + ...$state, + isExecuting: false, + hasExecuted: true, + result, + error: null, + executionTimeMs: Math.round(performance.now() - startedAt), + lastExecution: execution, + })); + } catch (error) { + if (currentController.signal.aborted) return; + + // Leave result and executionTimeMs untouched so the last successful + // results stay visible below the error banner. + state.update(($state) => ({ + ...$state, + isExecuting: false, + hasExecuted: true, + error: extractErrorMessage(error), + })); + } finally { + if (abortController === currentController) abortController = null; + } + } + + function cancel() { + abortController?.abort(); + abortController = null; + state.update(($state) => ({ ...$state, isExecuting: false })); + } + + function destroy() { + abortController?.abort(); + abortController = null; + } + + return { + subscribe: state.subscribe, + setSql, + execute, + cancel, + destroy, + }; +} + +export type SqlWorksheet = ReturnType; + +export function clearSqlWorksheetCache() { + worksheetCache.clear(); +} 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 000000000000..82cfc6608355 --- /dev/null +++ b/web-common/src/features/query/query-utils.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { getStatementRange, trimRange } from "./query-utils"; + +function statementAt(sql: string, cursor: number) { + const range = getStatementRange(sql, cursor); + return range ? sql.slice(range.from, range.to) : null; +} + +describe("getStatementRange", () => { + it("returns a single statement without requiring a semicolon", () => { + const sql = "\n SELECT * FROM events \n"; + + expect(statementAt(sql, sql.indexOf("events"))).toBe( + "SELECT * FROM events", + ); + }); + + it("returns the statement containing the cursor", () => { + const sql = "SELECT 1;\n\nSELECT 2;\nSELECT 3"; + + expect(statementAt(sql, sql.indexOf("2"))).toBe("SELECT 2;"); + }); + + it("ignores semicolons inside strings and quoted identifiers", () => { + const sql = `SELECT ';' AS value, "semi;colon" AS "name;";\nSELECT 2;`; + + expect(statementAt(sql, sql.indexOf("value"))).toBe( + `SELECT ';' AS value, "semi;colon" AS "name;";`, + ); + }); + + it("ignores semicolons inside comments", () => { + const sql = `-- explain; this query\nSELECT 1 /* ; */;\nSELECT 2;`; + + expect(statementAt(sql, sql.indexOf("1"))).toBe( + "-- explain; this query\nSELECT 1 /* ; */;", + ); + }); + + it("ignores semicolons inside dollar-quoted strings", () => { + const sql = "SELECT $$one;two$$ AS value;\nSELECT 2;"; + + expect(statementAt(sql, sql.indexOf("value"))).toBe( + "SELECT $$one;two$$ AS value;", + ); + }); + + it("falls back to the previous statement from trailing whitespace", () => { + const sql = "SELECT 1;\n\n"; + + expect(statementAt(sql, sql.length)).toBe("SELECT 1;"); + }); + + it("returns null for an empty worksheet", () => { + expect(getStatementRange(" \n", 1)).toBeNull(); + }); +}); + +describe("trimRange", () => { + it("trims selected whitespace without changing the selected SQL", () => { + const sql = "before SELECT 1; after"; + + expect(trimRange(sql, { from: 6, to: 19 })).toEqual({ + from: 8, + to: 17, + }); + }); +}); 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 000000000000..f31b61c9432f --- /dev/null +++ b/web-common/src/features/query/query-utils.ts @@ -0,0 +1,181 @@ +export interface TextRange { + from: number; + to: number; +} + +export interface SqlExecution extends TextRange { + sql: string; + startLine: number; + endLine: number; +} + +/** + * Finds the semicolon-delimited SQL statement containing the cursor. + * Semicolons inside comments and quoted values are ignored. This only chooses + * text to execute; it does not validate or classify the SQL. + */ +export function getStatementRange( + sql: string, + cursor: number, +): TextRange | null { + const ranges: TextRange[] = []; + let start = 0; + let index = 0; + let state: + | "normal" + | "single-quote" + | "double-quote" + | "backtick" + | "line-comment" + | "block-comment" = "normal"; + let dollarQuote = ""; + + while (index < sql.length) { + const character = sql[index]; + const nextCharacter = sql[index + 1]; + + if (dollarQuote) { + if (sql.startsWith(dollarQuote, index)) { + index += dollarQuote.length; + dollarQuote = ""; + } else { + index += 1; + } + continue; + } + + if (state === "line-comment") { + if (character === "\n") state = "normal"; + index += 1; + continue; + } + + if (state === "block-comment") { + if (character === "*" && nextCharacter === "/") { + state = "normal"; + index += 2; + } else { + index += 1; + } + continue; + } + + if (state === "single-quote") { + if (character === "'" && nextCharacter === "'") { + index += 2; + } else { + if (character === "'") state = "normal"; + index += 1; + } + continue; + } + + if (state === "double-quote") { + if (character === '"' && nextCharacter === '"') { + index += 2; + } else { + if (character === '"') state = "normal"; + index += 1; + } + continue; + } + + if (state === "backtick") { + if (character === "`" && nextCharacter === "`") { + index += 2; + } else { + if (character === "`") state = "normal"; + index += 1; + } + continue; + } + + if (character === "-" && nextCharacter === "-") { + state = "line-comment"; + index += 2; + continue; + } + if (character === "/" && nextCharacter === "*") { + state = "block-comment"; + index += 2; + continue; + } + if (character === "'") { + state = "single-quote"; + index += 1; + continue; + } + if (character === '"') { + state = "double-quote"; + index += 1; + continue; + } + if (character === "`") { + state = "backtick"; + index += 1; + continue; + } + if (character === "$") { + const match = sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/); + if (match) { + dollarQuote = match[0]; + index += dollarQuote.length; + continue; + } + } + if (character === ";") { + ranges.push({ from: start, to: index + 1 }); + start = index + 1; + } + index += 1; + } + + ranges.push({ from: start, to: sql.length }); + + const clampedCursor = Math.max(0, Math.min(cursor, sql.length)); + let selectedIndex = ranges.findIndex( + (range) => + clampedCursor >= range.from && + (clampedCursor < range.to || + (clampedCursor === sql.length && range.to === sql.length)), + ); + if (selectedIndex === -1) selectedIndex = ranges.length - 1; + + const selected = trimRange(sql, ranges[selectedIndex]); + if (selected) return selected; + + for (let offset = 1; offset < ranges.length; offset += 1) { + const previous = ranges[selectedIndex - offset]; + if (previous) { + const trimmed = trimRange(sql, previous); + if (trimmed) return trimmed; + } + + const next = ranges[selectedIndex + offset]; + if (next) { + const trimmed = trimRange(sql, next); + if (trimmed) return trimmed; + } + } + + return null; +} + +export function trimRange(sql: string, range: TextRange): TextRange | null { + let { from, to } = range; + while (from < to && /\s/.test(sql[from])) from += 1; + while (to > from && /\s/.test(sql[to - 1])) to -= 1; + return from === to ? null : { from, to }; +} + +export function formatDataType(code: string | undefined): string { + if (!code) return "UNKNOWN"; + const normalized = code.replace(/^CODE_/, ""); + return normalized.startsWith("UNKNOWN(") ? "UNKNOWN" : normalized; +} + +export function formatExecutionTime(milliseconds: number): string { + return milliseconds < 1000 + ? `${milliseconds}ms` + : `${(milliseconds / 1000).toFixed(1)}s`; +} diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 5df177470ab4..66b92097ba68 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -1289,7 +1289,7 @@ "nav_tab_alerts": "Alerts", "nav_tab_dashboards": "Dashboards", "nav_tab_home": "Home", - "nav_tab_query": "Query", + "nav_tab_query": "SQL Console", "nav_tab_reports": "Reports", "nav_tab_settings": "Settings", "nav_tab_status": "Status", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index 089440a67a01..f08464df92f5 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -1298,7 +1298,7 @@ "nav_tab_alerts": "Alertas", "nav_tab_dashboards": "Dashboards", "nav_tab_home": "Inicio", - "nav_tab_query": "Consulta", + "nav_tab_query": "Consola SQL", "nav_tab_reports": "Informes", "nav_tab_settings": "Configuración", "nav_tab_status": "Estado",