diff --git a/frontend/src/components/pages/sql/catalog-tree.test.tsx b/frontend/src/components/pages/sql/catalog-tree.test.tsx index 30e719752c..e481ae4531 100644 --- a/frontend/src/components/pages/sql/catalog-tree.test.tsx +++ b/frontend/src/components/pages/sql/catalog-tree.test.tsx @@ -98,7 +98,7 @@ describe('CatalogTree', () => { }); test('expanding a table lists its columns with type labels', async () => { - // Types arrive lower-cased from the backend; composite columns as "json" + // Types arrive lower-cased from the backend; composite columns as "record" // with their nested fields parsed server-side. vi.mocked(useDescribeTableQuery).mockReturnValue({ data: { @@ -106,7 +106,7 @@ describe('CatalogTree', () => { { name: 'id', type: 'bigint' }, { name: 'payload', type: 'jsonb' }, { name: 'tags', type: 'text[]' }, - { name: 'customer', type: 'json', fields: [{ name: 'street', type: 'text' }] }, + { name: 'customer', type: 'record', fields: [{ name: 'street', type: 'text' }] }, ], }, isLoading: false, @@ -119,7 +119,7 @@ describe('CatalogTree', () => { expect(screen.getByText('jsonb')).toBeInTheDocument(); expect(screen.getByText('text[]')).toBeInTheDocument(); - // Composite column shows "json" and expands into its nested fields. + // Composite column shows "record" and expands into its nested fields. const customerRow = screen.getByRole('button', { name: CUSTOMER_RE }); expect(customerRow).toBeInTheDocument(); expect(screen.queryByText('street')).not.toBeInTheDocument(); diff --git a/frontend/src/components/pages/sql/sql-editor.tsx b/frontend/src/components/pages/sql/sql-editor.tsx index c1198f5a1d..b34ef6009f 100644 --- a/frontend/src/components/pages/sql/sql-editor.tsx +++ b/frontend/src/components/pages/sql/sql-editor.tsx @@ -15,7 +15,7 @@ import { type CompletionResult, startCompletion, } from '@codemirror/autocomplete'; -import { PostgreSQL, type SQLNamespace, sql as sqlLanguage } from '@codemirror/lang-sql'; +import { PostgreSQL, type SQLNamespace, schemaCompletionSource, sql as sqlLanguage } from '@codemirror/lang-sql'; import { HighlightStyle, indentUnit, syntaxHighlighting, syntaxTree } from '@codemirror/language'; import { EditorState, type Extension, Prec } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; @@ -121,8 +121,6 @@ function useIsDarkMode(): boolean { // muted gutter line numbers. CodeMirror themes are plain CSS, so registry // custom properties can be referenced directly and stay live. function editorChrome(mode: 'light' | 'dark'): Extension { - const gutter = mode === 'dark' ? 'var(--color-grey-600)' : 'var(--color-grey-400)'; - const gutterActive = mode === 'dark' ? 'var(--color-grey-400)' : 'var(--color-grey-600)'; return EditorView.theme( { '&': { backgroundColor: 'transparent', height: '100%', fontSize: '13px' }, @@ -132,58 +130,39 @@ function editorChrome(mode: 'light' | 'dark'): Extension { lineHeight: '21px', }, '.cm-content': { padding: '12px 0' }, - '.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: gutter }, - '.cm-activeLineGutter': { backgroundColor: 'transparent', color: gutterActive }, - '.cm-activeLine': { - backgroundColor: mode === 'dark' ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)', - }, + '.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: 'var(--color-muted-foreground)' }, + '.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--color-foreground)' }, + '.cm-activeLine': { backgroundColor: 'var(--color-surface-default-hover)' }, }, { dark: mode === 'dark' } ); } -// SQL syntax palette, mapped from the design's `.sql-*` token classes onto the -// Lezer highlight tags the SQL grammar emits (keywords, built-ins, strings, -// numbers, comments, operators/punctuation and identifiers). -function sqlHighlight(mode: 'light' | 'dark'): Extension { - const c = - mode === 'dark' - ? { - keyword: 'var(--color-purple-300)', - fn: 'var(--color-indigo-300)', - str: 'var(--color-green-300)', - num: 'var(--color-orange-300)', - comment: 'var(--color-grey-400)', - punct: 'var(--color-grey-500)', - id: 'var(--color-grey-100)', - } - : { - keyword: 'var(--color-purple-700)', - fn: 'var(--color-indigo-600)', - str: 'var(--color-green-700)', - num: 'var(--color-orange-700)', - comment: 'var(--color-grey-600)', - punct: 'var(--color-grey-500)', - id: 'var(--color-grey-900)', - }; +// SQL syntax palette mapped onto the Lezer highlight tags the SQL grammar +// emits, entirely from theme-adaptive semantic tokens so it tracks light/dark +// without per-mode values. +function sqlHighlight(): Extension { return syntaxHighlighting( HighlightStyle.define([ - { tag: tags.keyword, color: c.keyword, fontWeight: 'bold' }, - { tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], color: c.fn }, - { tag: [tags.string, tags.special(tags.string)], color: c.str }, - { tag: tags.number, color: c.num }, - { tag: tags.comment, color: c.comment, fontStyle: 'italic' }, + { tag: tags.keyword, color: 'var(--color-secondary)', fontWeight: 'bold' }, + { + tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], + color: 'var(--color-primary)', + }, + { tag: [tags.string, tags.special(tags.string)], color: 'var(--color-success)' }, + { tag: tags.number, color: 'var(--color-warning)' }, + { tag: tags.comment, color: 'var(--color-muted-foreground)', fontStyle: 'italic' }, { tag: [tags.operator, tags.punctuation, tags.separator, tags.paren, tags.brace, tags.squareBracket], - color: c.punct, + color: 'var(--color-muted-foreground)', }, - { tag: tags.name, color: c.id }, + { tag: tags.name, color: 'var(--color-foreground)' }, ]) ); } -const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight('light')]; -const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight('dark')]; +const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight()]; +const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight()]; function tableNamespace(table: TableRef): SQLNamespace { return { @@ -192,12 +171,9 @@ function tableNamespace(table: TableRef): SQLNamespace { }; } -// Builds the lang-sql completion schema from the loaded catalog tree: bare -// table names → columns. Tables are deliberately NOT nested under their -// catalog — Redpanda SQL (Oxla) addresses catalog tables with arrow notation -// (`catalog=>table`), which catalogArrowSource below handles; dot-style -// nesting would advertise syntax the server rejects. Bare entries still give -// alias/column resolution (`FROM default_redpanda_catalog=>cars c` → `c.`). +// Bare table name → columns. Powers alias/column resolution only +// (schemaColumnSource); tables aren't nested under catalogs since Oxla uses +// `catalog=>table` arrow notation, handled by catalogArrowSource. function buildSchema(catalogs: Catalog[]): SQLNamespace { const root: Record = {}; for (const catalog of catalogs) { @@ -212,6 +188,20 @@ function buildSchema(catalogs: Catalog[]): SQLNamespace { return root; } +// Schema completion limited to dotted members (`c.` → columns); bare table +// names are suppressed at the top level so catalogArrowSource owns them. +function schemaColumnSource(catalogs: Catalog[]): (context: CompletionContext) => CompletionResult | null { + const source = schemaCompletionSource({ dialect: PostgreSQL, schema: buildSchema(catalogs) }); + return (context) => { + const result = source(context); + if (!result || result instanceof Promise) { + return null; + } + const dotted = context.state.sliceDoc(result.from - 1, result.from) === '.'; + return dotted ? result : null; + }; +} + // Matches an identifier followed by `=>` or `.` and a partial table name, // anchored at the cursor: [, name, gap1, separator, gap2, quote, partial]. const CATALOG_REF_RE = /([A-Za-z_][\w$]*)(\s*)(=>|\.)(\s*)("?)([\w$]*)$/; @@ -415,7 +405,9 @@ export const SqlEditor = forwardRef( }; const extensions = useMemo(() => { - const sqlSupport = sqlLanguage({ dialect: PostgreSQL, schema: buildSchema(catalogs), upperCaseKeywords: true }); + // No `schema` here — schemaColumnSource adds it back for dotted + // completions only, avoiding bare table names at the top level. + const sqlSupport = sqlLanguage({ dialect: PostgreSQL, upperCaseKeywords: true }); return [ // Prec.highest so Mod-Enter beats the default keymap's insertBlankLine. Prec.highest( @@ -441,6 +433,7 @@ export const SqlEditor = forwardRef( ), sqlSupport, sqlSupport.language.data.of({ autocomplete: catalogArrowSource(catalogs) }), + sqlSupport.language.data.of({ autocomplete: schemaColumnSource(catalogs) }), isDark ? DARK_THEME : LIGHT_THEME, EditorView.updateListener.of((update) => { if (update.selectionSet) { diff --git a/frontend/src/components/pages/sql/sql-results.tsx b/frontend/src/components/pages/sql/sql-results.tsx index f40fea2ecf..76ed2777bf 100644 --- a/frontend/src/components/pages/sql/sql-results.tsx +++ b/frontend/src/components/pages/sql/sql-results.tsx @@ -28,7 +28,19 @@ import { Spinner } from 'components/redpanda-ui/components/spinner'; import { StatusDot } from 'components/redpanda-ui/components/status-dot'; import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; -import { Braces, CircleX, Clock, Database, Download, GitMerge, Plus, Rows3, Terminal, X } from 'lucide-react'; +import { + Braces, + CircleX, + Clock, + Database, + Download, + GitMerge, + Lightbulb, + Plus, + Rows3, + Terminal, + X, +} from 'lucide-react'; import { createContext, useContext, useMemo, useState } from 'react'; import DataGrid, { type Column } from 'react-data-grid'; import { isMacOS } from 'utils/platform'; @@ -476,14 +488,17 @@ export function SqlResults({ run, sqlRole, onAddTable, hasTables = true }: SqlRe {run.message} {run.hint ? ( -
- {run.hint} - {run.hintAction && sqlRole === 'admin' && onAddTable ? ( - - ) : null} -
+ } variant="info"> + Hint + + {run.hint} + {run.hintAction && sqlRole === 'admin' && onAddTable ? ( + + ) : null} + + ) : null} ); diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts index 19f68d409d..9e4def5dc1 100644 --- a/frontend/src/components/pages/sql/sql-types.test.ts +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -9,9 +9,12 @@ * by the Apache License, Version 2.0 */ +import { create } from '@bufbuild/protobuf'; +import { ConnectError } from '@connectrpc/connect'; +import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb'; import { describe, expect, test } from 'vitest'; -import { arrayElementPgType, columnKindForPgType, isArrayPgType } from './sql-types'; +import { arrayElementPgType, columnKindForPgType, hintFromError, isArrayPgType } from './sql-types'; describe('columnKindForPgType', () => { test.each([ @@ -28,9 +31,10 @@ describe('columnKindForPgType', () => { ['JSONB', 'json'], ['TEXT', 'str'], ['UNKNOWN_TYPE', 'str'], - // Composite columns arrive pre-labelled as "json"/"json[]" from the backend. - ['json', 'json'], - ['json[]', 'json'], + // Composite columns arrive pre-labelled as "record"/"record[]" from the + // backend and render with the JSON tree viewer. + ['record', 'json'], + ['record[]', 'json'], ] as const)('%s → %s', (pgType, kind) => { expect(columnKindForPgType(pgType)).toBe(kind); }); @@ -62,3 +66,20 @@ describe('arrayElementPgType', () => { expect(isArrayPgType('TEXT[]')).toBe(true); }); }); + +describe('hintFromError', () => { + test('reads the hint from ErrorInfo metadata', () => { + const error = new ConnectError('boom', undefined, undefined, [ + { + desc: ErrorInfoSchema, + value: create(ErrorInfoSchema, { reason: 'REASON_INVALID_INPUT', metadata: { hint: 'use (customer).id' } }), + }, + ]); + expect(hintFromError(error)).toBe('use (customer).id'); + }); + + test('returns undefined when there is no ErrorInfo hint', () => { + expect(hintFromError(new ConnectError('boom'))).toBeUndefined(); + expect(hintFromError(new Error('plain'))).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts index 6ed007962f..6cdc29a3fc 100644 --- a/frontend/src/components/pages/sql/sql-types.ts +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -9,6 +9,9 @@ * by the Apache License, Version 2.0 */ +import { ConnectError } from '@connectrpc/connect'; +import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb'; + // Shared types for the SQL workspace, so the leaf components (catalog tree, // editor, results) and the data layer in sql-workspace agree on shape without // importing from each other. @@ -133,8 +136,8 @@ export function isArrayPgType(pgType: string): boolean { } // Maps a Postgres type name to a display kind. Composite columns arrive as the -// literal "json"/"json[]" (the backend parses structure into Column.fields), so -// they fall through to the JSON branch. Arrays map to their element kind; +// literal "record"/"record[]" (the backend parses structure into Column.fields) +// and render with the JSON tree viewer. Arrays map to their element kind; // anything unrecognized defaults to a string. export function columnKindForPgType(pgType: string): ColumnKind { const element = arrayElementPgType(pgType); @@ -152,12 +155,25 @@ export function columnKindForPgType(pgType: string): ColumnKind { if (BOOL_TYPE.test(t)) { return 'bool'; } - if (t.includes('JSON')) { + // RECORD/RECORD[] are composite columns; JSON kept for any real json scalar. + if (t.includes('RECORD') || t.includes('JSON')) { return 'json'; } return 'str'; } +// Structured hint from the Connect error's ErrorInfo metadata. undefined when absent. +export function hintFromError(error: unknown): string | undefined { + if (error instanceof ConnectError) { + for (const info of error.findDetails(ErrorInfoSchema)) { + if (info.metadata.hint) { + return info.metadata.hint; + } + } + } + return; +} + // Word-boundary anchored so geometric/temporal names that merely contain a // numeric token (POINT → INT, INTERVAL → INT) don't get misread as numeric. const NUMERIC_TYPE = /\b(?:INT|INTEGER|SMALLINT|BIGINT|FLOAT|NUMERIC|DECIMAL|DOUBLE|REAL|SERIAL|MONEY)/; diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index 0d71a27b8b..367e7bc437 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -50,6 +50,7 @@ import { type CellValue, type ColumnDef, columnKindForPgType, + hintFromError, isArrayPgType, type QueryRun, type ResultRow, @@ -546,7 +547,7 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { if (latestRunToken.current !== token) { return; } - setRun({ state: 'error', token, title: 'Query failed', message: error.message }); + setRun({ state: 'error', token, title: 'Query failed', message: error.message, hint: hintFromError(error) }); }, }); }, diff --git a/frontend/src/react-query/api/sql.tsx b/frontend/src/react-query/api/sql.tsx index 8254c748c3..fab4a4ea93 100644 --- a/frontend/src/react-query/api/sql.tsx +++ b/frontend/src/react-query/api/sql.tsx @@ -31,8 +31,6 @@ import { listTables, } from 'protogen/redpanda/api/dataplane/v1alpha3/sql-SQLService_connectquery'; import { MAX_PAGE_SIZE, type MessageInit } from 'react-query/react-query.utils'; -import { toast } from 'sonner'; -import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; type SqlQueryOptions = { enabled?: boolean; @@ -94,10 +92,8 @@ export const useTopicIcebergQuery = (topicName: string, options?: SqlQueryOption return { ...result, isIceberg: Boolean(mode && mode !== 'disabled') }; }; -export const useExecuteQueryMutation = () => - useMutation(executeQuery, { - onError: (error) => toast.error(formatToastErrorMessageGRPC({ error, action: 'execute', entity: 'SQL query' })), - }); +// Errors surface inline (run panel / wizard), so no toast on failure. +export const useExecuteQueryMutation = () => useMutation(executeQuery); // Returns a function that refreshes the catalog/table listings, e.g. after a // CREATE TABLE so the new table shows up in the tree.