UNPKG

datajunction-ui

Version:
344 lines (326 loc) 12.4 kB
/** * SQL query input field, which consists of a CodeMirror SQL editor with autocompletion * (for node names and columns) and syntax highlighting. */ import React from 'react'; import { ErrorMessage, Field, useFormikContext } from 'formik'; import CodeMirror from '@uiw/react-codemirror'; import { langs } from '@uiw/codemirror-extensions-langs'; import { extractCatalogTables } from './catalogTables'; import { djNodeBadges, djNodeHoverTooltip, refreshBadges, } from './djNodeBadges'; import { djEditorExtensions } from './djEditorTheme'; // Friendly labels for the most common DJ error codes the validate endpoint // returns from a node query. Falls back to a title-cased version of the code. const ERROR_CODE_LABELS = { INVALID_SQL_QUERY: 'Invalid SQL', TYPE_INFERENCE: 'Type inference', INVALID_COLUMN: 'Invalid column', INVALID_ARGUMENTS_TO_FUNCTION: 'Invalid function arguments', MISSING_COLUMNS: 'Missing columns', UNKNOWN_NODE: 'Unknown node', MISSING_PARENT: 'Missing parent', NODE_TYPE_ERROR: 'Node type', NOT_IMPLEMENTED_ERROR: 'Not implemented', }; const errorLabel = code => { if (code === undefined || code === null || code === '') return 'Error'; const key = String(code); if (ERROR_CODE_LABELS[key]) return ERROR_CODE_LABELS[key]; return key .toLowerCase() .split('_') .map(word => (word ? word[0].toUpperCase() + word.slice(1) : '')) .join(' '); }; export const NodeQueryField = ({ djClient, value }) => { // Schema is `{ [nodeName]: string[] }` — passed to @codemirror/lang-sql so // it can offer table + column autocompletion. Must be an OBJECT, not array, // and every update must produce a new reference so React re-renders and // langs.sql() rebuilds with the latest map. const [schema, setSchema] = React.useState({}); const formik = useFormikContext(); const sqlExt = React.useMemo(() => langs.sql({ schema }), [schema]); const autoRegisterTimer = React.useRef(null); const validateTimer = React.useRef(null); const registeredTables = React.useRef(new Set()); // useRef so the onChange closure always sees the latest catalog list const knownCatalogsRef = React.useRef([]); // Query-level validation errors (parse failures, type-inference failures, etc.) // surfaced from /nodes/validate's `errors` field. Rendered as a banner under // the editor so the user sees *why* their SQL is invalid, not just that it is. const [queryErrors, setQueryErrors] = React.useState([]); // Per-`catalog.schema.table` registration status, surfaced as badges in // the editor. Mirrored into a ref so the CodeMirror extension (built once, // memoized below) can read the latest value without rebuilding. const [tableStatus, setTableStatus] = React.useState({}); const tableStatusRef = React.useRef(tableStatus); React.useEffect(() => { tableStatusRef.current = tableStatus; }, [tableStatus]); const editorViewRef = React.useRef(null); const setStatus = React.useCallback((key, status) => { // Update the ref synchronously so the ViewPlugin's getStatus() closure // sees the new value on the redraw we're about to dispatch. // setTableStatus only schedules the React rerender (next tick) which is // too late for the dispatch below. tableStatusRef.current = { ...tableStatusRef.current, [key]: status }; setTableStatus(tableStatusRef.current); if (editorViewRef.current) { editorViewRef.current.dispatch({ effects: refreshBadges.of(null) }); } }, []); React.useEffect(() => { if (typeof djClient.catalogs !== 'function') return; Promise.resolve(djClient.catalogs()) .then(catalogs => { knownCatalogsRef.current = (catalogs || []).map(c => c.name.toLowerCase(), ); }) .catch(() => { // Auto-register is best-effort; failing to load catalogs just disables it. }); }, [djClient]); const initialAutocomplete = async context => { // Based on the parsed prefix, we load node names with that prefix // into the autocomplete schema. At this stage we don't load the columns // to save on unnecessary calls const word = context.matchBefore(/[\.\w]*/); const matches = await djClient.nodes(word.text); setSchema(prev => { const next = { ...prev }; let changed = false; for (const nodeName of matches) { if (next[nodeName] === undefined) { next[nodeName] = []; changed = true; } } return changed ? next : prev; }); }; const updateFormik = val => { formik.setFieldValue('query', val); }; const updateAutocomplete = async (value, _) => { // If a particular node has been chosen, load the columns of that node into // the autocomplete schema for column-level autocompletion. Mutate via a // functional setState so every update produces a new map reference and // langs.sql sees the change. for (const nodeName of Object.keys(schema)) { if (value.includes(nodeName) && schema[nodeName].length === 0) { const nodeDetails = await djClient.node(nodeName); const cols = (nodeDetails?.columns || []).map(col => col.name); setSchema(prev => ({ ...prev, [nodeName]: cols })); } } // Auto-register any catalog-qualified tables typed in the SQL if (knownCatalogsRef.current.length > 0) { clearTimeout(autoRegisterTimer.current); autoRegisterTimer.current = setTimeout( () => autoRegisterCatalogTables(value), 600, ); } // Validate the in-progress query against DJ to discover which 2-part // refs (`namespace.name`) resolve to real nodes vs. are missing. clearTimeout(validateTimer.current); validateTimer.current = setTimeout(() => validateRefs(value), 700); }; const validateRefs = async sql => { if (typeof djClient.validateNode !== 'function') return; const fv = formik.values || {}; if (!fv.type) return; // need a node type to validate let response; try { response = await djClient.validateNode( fv.type, fv.name || '__draft__', fv.display_name || 'Draft', fv.description || '', sql, ); } catch { return; // best-effort; ignore network failures } const json = response?.json || {}; const deps = json.dependencies || []; const missing = json.missing_parents || []; setQueryErrors(Array.isArray(json.errors) ? json.errors : []); // Update the ref synchronously in one shot before dispatching, so the // ViewPlugin only redraws once even though we set many keys. const next = { ...tableStatusRef.current }; for (const dep of deps) { // `dependencies` is a list of full node objects ({name, type, status, ...}). // Use `type` to color the chip per node kind (source/dimension/…) and // `status` to flag nodes that resolve but are themselves unhealthy // (e.g. broken upstream, drifted columns). We also keep the whole // object around so the hover popover can show description / version // / mode without re-fetching. const name = typeof dep === 'string' ? dep : dep?.name; if (!name) continue; const nodeType = typeof dep === 'object' ? dep.type : undefined; const nodeStatus = typeof dep === 'object' ? dep.status : undefined; const kind = nodeStatus && nodeStatus !== 'valid' ? 'warning' : 'valid'; next[name] = { kind, refType: nodeType || 'node', node: typeof dep === 'object' ? dep : undefined, message: kind === 'warning' ? `Node \`${name}\` is ${nodeStatus}` : undefined, }; } for (const m of missing) { // `missing_parents` is a list of bare name strings. const name = typeof m === 'string' ? m : m?.name; if (!name) continue; next[name] = { kind: 'invalid', refType: 'node', message: `Node \`${name}\` not found`, }; } tableStatusRef.current = next; setTableStatus(next); if (editorViewRef.current) { editorViewRef.current.dispatch({ effects: refreshBadges.of(null) }); } }; const autoRegisterCatalogTables = async sql => { const tables = extractCatalogTables(sql, knownCatalogsRef.current); for (const [catalog, tableSchema, table] of tables) { const key = `${catalog}.${tableSchema}.${table}`; if (registeredTables.current.has(key)) continue; // If autocomplete already saw this node, DJ knows about it — skip silently if (schema[key] !== undefined) { registeredTables.current.add(key); setStatus(key, { kind: 'valid' }); continue; } registeredTables.current.add(key); setStatus(key, { kind: 'registering' }); let response; try { response = await djClient.registerTable( catalog, tableSchema, table, '', ); } catch (err) { registeredTables.current.delete(key); setStatus(key, { kind: 'invalid', message: err?.message || 'Failed to register table', }); continue; } const { status, json } = response; if (status === 200 || status === 201) { const nodeName = json?.name || key; const columns = (json?.columns || []).map(col => col.name); setSchema(prev => { const next = { ...prev, [nodeName]: columns }; if (table && table !== nodeName) next[table] = columns; return next; }); setStatus(key, { kind: 'valid' }); } else if (status === 409) { // 409 = already exists; treat as valid. setStatus(key, { kind: 'valid' }); } else { registeredTables.current.delete(key); setStatus(key, { kind: 'invalid', message: json?.message || `Registration failed (HTTP ${status})`, }); } } }; const badgeExt = React.useMemo( () => djNodeBadges({ getStatus: key => tableStatusRef.current[key], getKnownCatalogs: () => knownCatalogsRef.current, }), [], ); const hoverExt = React.useMemo( () => djNodeHoverTooltip({ getStatus: key => tableStatusRef.current[key], getKnownCatalogs: () => knownCatalogsRef.current, fetchNodeDetails: name => djClient.node(name).catch(() => null), }), [djClient], ); return ( <div className="QueryInput NodeCreationInput"> <ErrorMessage name="query" component="span" /> <label htmlFor="Query">Query *</label> <Field type="textarea" style={{ display: 'none' }} as="textarea" name="query" id="Query" /> {queryErrors.length > 0 && ( <div className="query-validation-errors" role="alert"> {queryErrors.map((err, idx) => ( <div key={idx} className="query-validation-error"> <span className="query-validation-error-code"> {errorLabel(err.code)} </span> <span className="query-validation-error-message"> {err.message} </span> </div> ))} </div> )} <div role="button" tabIndex={0} className="relative flex bg-[#282a36]"> <CodeMirror id={'query'} name={'query'} extensions={[ sqlExt, sqlExt.language.data.of({ autocomplete: initialAutocomplete, }), ...djEditorExtensions, badgeExt, hoverExt, ]} onCreateEditor={view => { editorViewRef.current = view; }} value={value} placeholder={ 'SELECT\n\tprimary_key,\n\tmeasure1,\n\tmeasure2,\n\tforeign_key_for_dimension1,\n\tforeign_key_for_dimension2\nFROM source.source_node\nWHERE ...' } options={{ theme: 'default', lineNumbers: true, }} width="100%" height="400px" style={{ margin: '0 0 23px 0', flex: 1, fontSize: '110%', textAlign: 'left', }} onChange={(value, viewUpdate) => { updateFormik(value); updateAutocomplete(value, viewUpdate); }} /> </div> </div> ); };