Skip to content

Commit 2f0436b

Browse files
authored
sql: render "record" composite columns with the JSON tree viewer [UX-1330] (#2533)
* sql: render "record" composite columns with the JSON tree viewer [UX-1330] The backend now labels composite (struct) columns "record"/"record[]" instead of "json"/"json[]". Map those type strings to the json display kind so composite cells keep the nested JSON tree viewer and the catalog tree/cell popover show the "record" label. * sql: show the query-error hint on its own line [UX-1330] The backend now appends an actionable hint after a blank line. Split it off the error message and render it in the existing styled hint slot below the alert, instead of inline in the message body. * sql: render the query-error hint as a labelled info callout [UX-1330] Show the hint in an info Alert with a lightbulb icon and a "Hint" title instead of loose muted text, so it reads clearly as guidance rather than part of the error. * style(sql): apply ultracite lint fixes (import order, formatting) [UX-1330] * feat(sql): read record-column hint from structured ErrorInfo metadata [UX-1330] * refactor(sql): drop message-string hint parsing, read structured metadata only [UX-1330] * refactor(sql): drop query success/error toasts in favor of inline feedback [UX-1330] * refactor(sql): keep table-created success toast [UX-1330] * fix(sql): scope autocomplete table names to the selected catalog * style(sql): map editor syntax palette to semantic theme tokens * style(sql): move editor gutter, syntax greys and active line to semantic tokens
1 parent 8cbb4d5 commit 2f0436b

7 files changed

Lines changed: 116 additions & 74 deletions

File tree

frontend/src/components/pages/sql/catalog-tree.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,15 @@ describe('CatalogTree', () => {
9898
});
9999

100100
test('expanding a table lists its columns with type labels', async () => {
101-
// Types arrive lower-cased from the backend; composite columns as "json"
101+
// Types arrive lower-cased from the backend; composite columns as "record"
102102
// with their nested fields parsed server-side.
103103
vi.mocked(useDescribeTableQuery).mockReturnValue({
104104
data: {
105105
columns: [
106106
{ name: 'id', type: 'bigint' },
107107
{ name: 'payload', type: 'jsonb' },
108108
{ name: 'tags', type: 'text[]' },
109-
{ name: 'customer', type: 'json', fields: [{ name: 'street', type: 'text' }] },
109+
{ name: 'customer', type: 'record', fields: [{ name: 'street', type: 'text' }] },
110110
],
111111
},
112112
isLoading: false,
@@ -119,7 +119,7 @@ describe('CatalogTree', () => {
119119
expect(screen.getByText('jsonb')).toBeInTheDocument();
120120
expect(screen.getByText('text[]')).toBeInTheDocument();
121121

122-
// Composite column shows "json" and expands into its nested fields.
122+
// Composite column shows "record" and expands into its nested fields.
123123
const customerRow = screen.getByRole('button', { name: CUSTOMER_RE });
124124
expect(customerRow).toBeInTheDocument();
125125
expect(screen.queryByText('street')).not.toBeInTheDocument();

frontend/src/components/pages/sql/sql-editor.tsx

Lines changed: 41 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
type CompletionResult,
1616
startCompletion,
1717
} from '@codemirror/autocomplete';
18-
import { PostgreSQL, type SQLNamespace, sql as sqlLanguage } from '@codemirror/lang-sql';
18+
import { PostgreSQL, type SQLNamespace, schemaCompletionSource, sql as sqlLanguage } from '@codemirror/lang-sql';
1919
import { HighlightStyle, indentUnit, syntaxHighlighting, syntaxTree } from '@codemirror/language';
2020
import { EditorState, type Extension, Prec } from '@codemirror/state';
2121
import { EditorView, keymap } from '@codemirror/view';
@@ -121,8 +121,6 @@ function useIsDarkMode(): boolean {
121121
// muted gutter line numbers. CodeMirror themes are plain CSS, so registry
122122
// custom properties can be referenced directly and stay live.
123123
function editorChrome(mode: 'light' | 'dark'): Extension {
124-
const gutter = mode === 'dark' ? 'var(--color-grey-600)' : 'var(--color-grey-400)';
125-
const gutterActive = mode === 'dark' ? 'var(--color-grey-400)' : 'var(--color-grey-600)';
126124
return EditorView.theme(
127125
{
128126
'&': { backgroundColor: 'transparent', height: '100%', fontSize: '13px' },
@@ -132,58 +130,39 @@ function editorChrome(mode: 'light' | 'dark'): Extension {
132130
lineHeight: '21px',
133131
},
134132
'.cm-content': { padding: '12px 0' },
135-
'.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: gutter },
136-
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: gutterActive },
137-
'.cm-activeLine': {
138-
backgroundColor: mode === 'dark' ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)',
139-
},
133+
'.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: 'var(--color-muted-foreground)' },
134+
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--color-foreground)' },
135+
'.cm-activeLine': { backgroundColor: 'var(--color-surface-default-hover)' },
140136
},
141137
{ dark: mode === 'dark' }
142138
);
143139
}
144140

145-
// SQL syntax palette, mapped from the design's `.sql-*` token classes onto the
146-
// Lezer highlight tags the SQL grammar emits (keywords, built-ins, strings,
147-
// numbers, comments, operators/punctuation and identifiers).
148-
function sqlHighlight(mode: 'light' | 'dark'): Extension {
149-
const c =
150-
mode === 'dark'
151-
? {
152-
keyword: 'var(--color-purple-300)',
153-
fn: 'var(--color-indigo-300)',
154-
str: 'var(--color-green-300)',
155-
num: 'var(--color-orange-300)',
156-
comment: 'var(--color-grey-400)',
157-
punct: 'var(--color-grey-500)',
158-
id: 'var(--color-grey-100)',
159-
}
160-
: {
161-
keyword: 'var(--color-purple-700)',
162-
fn: 'var(--color-indigo-600)',
163-
str: 'var(--color-green-700)',
164-
num: 'var(--color-orange-700)',
165-
comment: 'var(--color-grey-600)',
166-
punct: 'var(--color-grey-500)',
167-
id: 'var(--color-grey-900)',
168-
};
141+
// SQL syntax palette mapped onto the Lezer highlight tags the SQL grammar
142+
// emits, entirely from theme-adaptive semantic tokens so it tracks light/dark
143+
// without per-mode values.
144+
function sqlHighlight(): Extension {
169145
return syntaxHighlighting(
170146
HighlightStyle.define([
171-
{ tag: tags.keyword, color: c.keyword, fontWeight: 'bold' },
172-
{ tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], color: c.fn },
173-
{ tag: [tags.string, tags.special(tags.string)], color: c.str },
174-
{ tag: tags.number, color: c.num },
175-
{ tag: tags.comment, color: c.comment, fontStyle: 'italic' },
147+
{ tag: tags.keyword, color: 'var(--color-secondary)', fontWeight: 'bold' },
148+
{
149+
tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName],
150+
color: 'var(--color-primary)',
151+
},
152+
{ tag: [tags.string, tags.special(tags.string)], color: 'var(--color-success)' },
153+
{ tag: tags.number, color: 'var(--color-warning)' },
154+
{ tag: tags.comment, color: 'var(--color-muted-foreground)', fontStyle: 'italic' },
176155
{
177156
tag: [tags.operator, tags.punctuation, tags.separator, tags.paren, tags.brace, tags.squareBracket],
178-
color: c.punct,
157+
color: 'var(--color-muted-foreground)',
179158
},
180-
{ tag: tags.name, color: c.id },
159+
{ tag: tags.name, color: 'var(--color-foreground)' },
181160
])
182161
);
183162
}
184163

185-
const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight('light')];
186-
const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight('dark')];
164+
const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight()];
165+
const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight()];
187166

188167
function tableNamespace(table: TableRef): SQLNamespace {
189168
return {
@@ -192,12 +171,9 @@ function tableNamespace(table: TableRef): SQLNamespace {
192171
};
193172
}
194173

195-
// Builds the lang-sql completion schema from the loaded catalog tree: bare
196-
// table names → columns. Tables are deliberately NOT nested under their
197-
// catalog — Redpanda SQL (Oxla) addresses catalog tables with arrow notation
198-
// (`catalog=>table`), which catalogArrowSource below handles; dot-style
199-
// nesting would advertise syntax the server rejects. Bare entries still give
200-
// alias/column resolution (`FROM default_redpanda_catalog=>cars c` → `c.`).
174+
// Bare table name → columns. Powers alias/column resolution only
175+
// (schemaColumnSource); tables aren't nested under catalogs since Oxla uses
176+
// `catalog=>table` arrow notation, handled by catalogArrowSource.
201177
function buildSchema(catalogs: Catalog[]): SQLNamespace {
202178
const root: Record<string, SQLNamespace> = {};
203179
for (const catalog of catalogs) {
@@ -212,6 +188,20 @@ function buildSchema(catalogs: Catalog[]): SQLNamespace {
212188
return root;
213189
}
214190

191+
// Schema completion limited to dotted members (`c.` → columns); bare table
192+
// names are suppressed at the top level so catalogArrowSource owns them.
193+
function schemaColumnSource(catalogs: Catalog[]): (context: CompletionContext) => CompletionResult | null {
194+
const source = schemaCompletionSource({ dialect: PostgreSQL, schema: buildSchema(catalogs) });
195+
return (context) => {
196+
const result = source(context);
197+
if (!result || result instanceof Promise) {
198+
return null;
199+
}
200+
const dotted = context.state.sliceDoc(result.from - 1, result.from) === '.';
201+
return dotted ? result : null;
202+
};
203+
}
204+
215205
// Matches an identifier followed by `=>` or `.` and a partial table name,
216206
// anchored at the cursor: [, name, gap1, separator, gap2, quote, partial].
217207
const CATALOG_REF_RE = /([A-Za-z_][\w$]*)(\s*)(=>|\.)(\s*)("?)([\w$]*)$/;
@@ -415,7 +405,9 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
415405
};
416406

417407
const extensions = useMemo(() => {
418-
const sqlSupport = sqlLanguage({ dialect: PostgreSQL, schema: buildSchema(catalogs), upperCaseKeywords: true });
408+
// No `schema` here — schemaColumnSource adds it back for dotted
409+
// completions only, avoiding bare table names at the top level.
410+
const sqlSupport = sqlLanguage({ dialect: PostgreSQL, upperCaseKeywords: true });
419411
return [
420412
// Prec.highest so Mod-Enter beats the default keymap's insertBlankLine.
421413
Prec.highest(
@@ -441,6 +433,7 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
441433
),
442434
sqlSupport,
443435
sqlSupport.language.data.of({ autocomplete: catalogArrowSource(catalogs) }),
436+
sqlSupport.language.data.of({ autocomplete: schemaColumnSource(catalogs) }),
444437
isDark ? DARK_THEME : LIGHT_THEME,
445438
EditorView.updateListener.of((update) => {
446439
if (update.selectionSet) {

frontend/src/components/pages/sql/sql-results.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,19 @@ import { Spinner } from 'components/redpanda-ui/components/spinner';
2828
import { StatusDot } from 'components/redpanda-ui/components/status-dot';
2929
import { InlineCode, Text } from 'components/redpanda-ui/components/typography';
3030
import { cn } from 'components/redpanda-ui/lib/utils';
31-
import { Braces, CircleX, Clock, Database, Download, GitMerge, Plus, Rows3, Terminal, X } from 'lucide-react';
31+
import {
32+
Braces,
33+
CircleX,
34+
Clock,
35+
Database,
36+
Download,
37+
GitMerge,
38+
Lightbulb,
39+
Plus,
40+
Rows3,
41+
Terminal,
42+
X,
43+
} from 'lucide-react';
3244
import { createContext, useContext, useMemo, useState } from 'react';
3345
import DataGrid, { type Column } from 'react-data-grid';
3446
import { isMacOS } from 'utils/platform';
@@ -476,14 +488,17 @@ export function SqlResults({ run, sqlRole, onAddTable, hasTables = true }: SqlRe
476488
<AlertDescription>{run.message}</AlertDescription>
477489
</Alert>
478490
{run.hint ? (
479-
<div className="flex items-center gap-3 text-muted-foreground text-sm">
480-
{run.hint}
481-
{run.hintAction && sqlRole === 'admin' && onAddTable ? (
482-
<Button onClick={onAddTable} size="sm" variant="primary">
483-
<Plus size={14} /> Add a topic to SQL
484-
</Button>
485-
) : null}
486-
</div>
491+
<Alert icon={<Lightbulb />} variant="info">
492+
<AlertTitle>Hint</AlertTitle>
493+
<AlertDescription>
494+
{run.hint}
495+
{run.hintAction && sqlRole === 'admin' && onAddTable ? (
496+
<Button onClick={onAddTable} size="sm" variant="primary">
497+
<Plus size={14} /> Add a topic to SQL
498+
</Button>
499+
) : null}
500+
</AlertDescription>
501+
</Alert>
487502
) : null}
488503
</div>
489504
);

frontend/src/components/pages/sql/sql-types.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
* by the Apache License, Version 2.0
1010
*/
1111

12+
import { create } from '@bufbuild/protobuf';
13+
import { ConnectError } from '@connectrpc/connect';
14+
import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb';
1215
import { describe, expect, test } from 'vitest';
1316

14-
import { arrayElementPgType, columnKindForPgType, isArrayPgType } from './sql-types';
17+
import { arrayElementPgType, columnKindForPgType, hintFromError, isArrayPgType } from './sql-types';
1518

1619
describe('columnKindForPgType', () => {
1720
test.each([
@@ -28,9 +31,10 @@ describe('columnKindForPgType', () => {
2831
['JSONB', 'json'],
2932
['TEXT', 'str'],
3033
['UNKNOWN_TYPE', 'str'],
31-
// Composite columns arrive pre-labelled as "json"/"json[]" from the backend.
32-
['json', 'json'],
33-
['json[]', 'json'],
34+
// Composite columns arrive pre-labelled as "record"/"record[]" from the
35+
// backend and render with the JSON tree viewer.
36+
['record', 'json'],
37+
['record[]', 'json'],
3438
] as const)('%s → %s', (pgType, kind) => {
3539
expect(columnKindForPgType(pgType)).toBe(kind);
3640
});
@@ -62,3 +66,20 @@ describe('arrayElementPgType', () => {
6266
expect(isArrayPgType('TEXT[]')).toBe(true);
6367
});
6468
});
69+
70+
describe('hintFromError', () => {
71+
test('reads the hint from ErrorInfo metadata', () => {
72+
const error = new ConnectError('boom', undefined, undefined, [
73+
{
74+
desc: ErrorInfoSchema,
75+
value: create(ErrorInfoSchema, { reason: 'REASON_INVALID_INPUT', metadata: { hint: 'use (customer).id' } }),
76+
},
77+
]);
78+
expect(hintFromError(error)).toBe('use (customer).id');
79+
});
80+
81+
test('returns undefined when there is no ErrorInfo hint', () => {
82+
expect(hintFromError(new ConnectError('boom'))).toBeUndefined();
83+
expect(hintFromError(new Error('plain'))).toBeUndefined();
84+
});
85+
});

frontend/src/components/pages/sql/sql-types.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
* by the Apache License, Version 2.0
1010
*/
1111

12+
import { ConnectError } from '@connectrpc/connect';
13+
import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb';
14+
1215
// Shared types for the SQL workspace, so the leaf components (catalog tree,
1316
// editor, results) and the data layer in sql-workspace agree on shape without
1417
// importing from each other.
@@ -133,8 +136,8 @@ export function isArrayPgType(pgType: string): boolean {
133136
}
134137

135138
// Maps a Postgres type name to a display kind. Composite columns arrive as the
136-
// literal "json"/"json[]" (the backend parses structure into Column.fields), so
137-
// they fall through to the JSON branch. Arrays map to their element kind;
139+
// literal "record"/"record[]" (the backend parses structure into Column.fields)
140+
// and render with the JSON tree viewer. Arrays map to their element kind;
138141
// anything unrecognized defaults to a string.
139142
export function columnKindForPgType(pgType: string): ColumnKind {
140143
const element = arrayElementPgType(pgType);
@@ -152,12 +155,25 @@ export function columnKindForPgType(pgType: string): ColumnKind {
152155
if (BOOL_TYPE.test(t)) {
153156
return 'bool';
154157
}
155-
if (t.includes('JSON')) {
158+
// RECORD/RECORD[] are composite columns; JSON kept for any real json scalar.
159+
if (t.includes('RECORD') || t.includes('JSON')) {
156160
return 'json';
157161
}
158162
return 'str';
159163
}
160164

165+
// Structured hint from the Connect error's ErrorInfo metadata. undefined when absent.
166+
export function hintFromError(error: unknown): string | undefined {
167+
if (error instanceof ConnectError) {
168+
for (const info of error.findDetails(ErrorInfoSchema)) {
169+
if (info.metadata.hint) {
170+
return info.metadata.hint;
171+
}
172+
}
173+
}
174+
return;
175+
}
176+
161177
// Word-boundary anchored so geometric/temporal names that merely contain a
162178
// numeric token (POINT → INT, INTERVAL → INT) don't get misread as numeric.
163179
const NUMERIC_TYPE = /\b(?:INT|INTEGER|SMALLINT|BIGINT|FLOAT|NUMERIC|DECIMAL|DOUBLE|REAL|SERIAL|MONEY)/;

frontend/src/components/pages/sql/sql-workspace.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
type CellValue,
5151
type ColumnDef,
5252
columnKindForPgType,
53+
hintFromError,
5354
isArrayPgType,
5455
type QueryRun,
5556
type ResultRow,
@@ -546,7 +547,7 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) {
546547
if (latestRunToken.current !== token) {
547548
return;
548549
}
549-
setRun({ state: 'error', token, title: 'Query failed', message: error.message });
550+
setRun({ state: 'error', token, title: 'Query failed', message: error.message, hint: hintFromError(error) });
550551
},
551552
});
552553
},

frontend/src/react-query/api/sql.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ import {
3131
listTables,
3232
} from 'protogen/redpanda/api/dataplane/v1alpha3/sql-SQLService_connectquery';
3333
import { MAX_PAGE_SIZE, type MessageInit } from 'react-query/react-query.utils';
34-
import { toast } from 'sonner';
35-
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
3634

3735
type SqlQueryOptions = {
3836
enabled?: boolean;
@@ -94,10 +92,8 @@ export const useTopicIcebergQuery = (topicName: string, options?: SqlQueryOption
9492
return { ...result, isIceberg: Boolean(mode && mode !== 'disabled') };
9593
};
9694

97-
export const useExecuteQueryMutation = () =>
98-
useMutation(executeQuery, {
99-
onError: (error) => toast.error(formatToastErrorMessageGRPC({ error, action: 'execute', entity: 'SQL query' })),
100-
});
95+
// Errors surface inline (run panel / wizard), so no toast on failure.
96+
export const useExecuteQueryMutation = () => useMutation(executeQuery);
10197

10298
// Returns a function that refreshes the catalog/table listings, e.g. after a
10399
// CREATE TABLE so the new table shows up in the tree.

0 commit comments

Comments
 (0)