datajunction-ui
Version:
DataJunction UI
344 lines (326 loc) • 12.4 kB
JSX
/**
* 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>
);
};