Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions frontend/src/components/pages/sql/catalog-tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ 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: {
columns: [
{ 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,
Expand All @@ -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();
Expand Down
89 changes: 41 additions & 48 deletions frontend/src/components/pages/sql/sql-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand All @@ -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 {
Expand All @@ -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<string, SQLNamespace> = {};
for (const catalog of catalogs) {
Expand All @@ -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$]*)$/;
Expand Down Expand Up @@ -415,7 +405,9 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
};

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(
Expand All @@ -441,6 +433,7 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
),
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) {
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/components/pages/sql/sql-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -476,14 +488,17 @@ export function SqlResults({ run, sqlRole, onAddTable, hasTables = true }: SqlRe
<AlertDescription>{run.message}</AlertDescription>
</Alert>
{run.hint ? (
<div className="flex items-center gap-3 text-muted-foreground text-sm">
{run.hint}
{run.hintAction && sqlRole === 'admin' && onAddTable ? (
<Button onClick={onAddTable} size="sm" variant="primary">
<Plus size={14} /> Add a topic to SQL
</Button>
) : null}
</div>
<Alert icon={<Lightbulb />} variant="info">
<AlertTitle>Hint</AlertTitle>
<AlertDescription>
{run.hint}
{run.hintAction && sqlRole === 'admin' && onAddTable ? (
<Button onClick={onAddTable} size="sm" variant="primary">
<Plus size={14} /> Add a topic to SQL
</Button>
) : null}
</AlertDescription>
</Alert>
) : null}
</div>
);
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/components/pages/sql/sql-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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);
});
Expand Down Expand Up @@ -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();
});
});
22 changes: 19 additions & 3 deletions frontend/src/components/pages/sql/sql-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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)/;
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/pages/sql/sql-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type CellValue,
type ColumnDef,
columnKindForPgType,
hintFromError,
isArrayPgType,
type QueryRun,
type ResultRow,
Expand Down Expand Up @@ -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) });
},
});
},
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/react-query/api/sql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading