diff --git a/rust/cubesql/cubesql/egraph-debug-template/package.json b/rust/cubesql/cubesql/egraph-debug-template/package.json index 52fd01baa8bcf..60c0f901087df 100644 --- a/rust/cubesql/cubesql/egraph-debug-template/package.json +++ b/rust/cubesql/cubesql/egraph-debug-template/package.json @@ -11,13 +11,20 @@ "scripts": { "start": "GENERATE_SOURCEMAP=false && react-scripts start", "build": "react-scripts build", + "check": "tsc", "reformat": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": { + "@tsconfig/strictest": "2.0.5", + "@types/node": "20.16.12", + "@types/react": "18.0.38", + "@types/react-dom": "18.0.11", "prettier": "3.3.3", - "react-scripts": "5.0.1" + "react-scripts": "5.0.1", + "typescript": "4.9.5", + "web-worker": "1.3.0" }, "eslintConfig": { "extends": [ diff --git a/rust/cubesql/cubesql/egraph-debug-template/src/index.js b/rust/cubesql/cubesql/egraph-debug-template/src/index.tsx similarity index 61% rename from rust/cubesql/cubesql/egraph-debug-template/src/index.js rename to rust/cubesql/cubesql/egraph-debug-template/src/index.tsx index a52dd170c1098..641a25369371c 100644 --- a/rust/cubesql/cubesql/egraph-debug-template/src/index.js +++ b/rust/cubesql/cubesql/egraph-debug-template/src/index.tsx @@ -1,7 +1,7 @@ -import states from './states.json'; import { createRoot } from 'react-dom/client'; -import ELK from 'elkjs/lib/elk.bundled.js'; -import React, { useCallback, useState, useEffect, useMemo } from 'react'; +import ELK from 'elkjs'; +import type { ElkNode, LayoutOptions } from 'elkjs'; +import { useCallback, useState, useEffect, useMemo } from 'react'; import ReactFlow, { ReactFlowProvider, Panel, @@ -11,9 +11,49 @@ import ReactFlow, { Handle, Position, } from 'reactflow'; - +import type { + Edge as ReactFlowEdge, + FitView, + Node as ReactFlowNode, + NodeProps, +} from 'reactflow'; import 'reactflow/dist/style.css'; +import statesData from './states.json'; + +type InputNodeData = { + id: string; + label: string; + comboId: string; +}; +type InputEdgeData = { + source: string; + target: string; +}; +type InputComboData = { + id: string; + label: string; +}; +type StateData = { + nodes: Array; + removedNodes: Array; + edges: Array; + removedEdges: Array; + combos: Array; + removedCombos: Array; + appliedRules: Array; +}; +type InputData = Array; + +type NodeData = { + label: string; +}; +type Node = ReactFlowNode; +type Edge = ReactFlowEdge; + +// TODO proper parsing here +const states = statesData as InputData; + // First is initial state const totalIterations = states.length - 1; const data = { @@ -21,8 +61,11 @@ const data = { edges: states[0].edges, combos: states[0].combos, }; -const sizeByNode = (n) => [60 + n.label.length * 5, 30]; -const toGroupNode = (n) => ({ +const sizeByNode = (n: InputNodeData): [number, number] => [ + 60 + n.label.length * 5, + 30, +]; +const toGroupNode = (n: InputComboData): Node => ({ ...n, type: 'group', data: { label: n.label }, @@ -30,7 +73,7 @@ const toGroupNode = (n) => ({ width: 200, height: 200, }); -const toRegularNode = (n) => ({ +const toRegularNode = (n: InputNodeData): Node => ({ ...n, type: 'default', extent: 'parent', @@ -41,33 +84,46 @@ const toRegularNode = (n) => ({ draggable: false, connectable: false, }); -const toEdge = (n) => ({ +const toEdge = (n: InputEdgeData): Edge => ({ ...n, id: `${n.source}->${n.target}`, style: n.source.indexOf(`${n.target}-`) === 0 - ? { stroke: '#f00', 'stroke-width': 10 } - : undefined, + ? { stroke: '#f00', strokeWidth: 10 } + : {}, }); const initialNodes = data.combos .map(toGroupNode) .concat(data.nodes.map(toRegularNode)); const initialEdges = data.edges.map(toEdge); -function layout( - options, - nodes, - edges, - setNodes, - setEdges, - fitView, - navHistory, - showOnlySelected, +const elk = new ELK({ + workerFactory: function (_url) { + // TODO something is broken with bundling and web-worker + return new Worker( + new URL( + '../node_modules/elkjs/lib/elk-worker.min.js', + import.meta.url, + ), + ); + }, +}); + +async function layout( + options: LayoutOptions, + nodes: Array, + edges: Array, + setNodes: (nodes: Array) => void, + setEdges: (nodes: Array) => void, + fitView: FitView, + navHistory: NavHistoryState, + showOnlySelected: boolean, + abortSignal: AbortSignal, ) { const defaultOptions = { 'elk.algorithm': 'layered', - 'elk.layered.spacing.nodeNodeBetweenLayers': 100, - 'elk.spacing.nodeNode': 80, + 'elk.layered.spacing.nodeNodeBetweenLayers': '100', + 'elk.spacing.nodeNode': '80', 'org.eclipse.elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.direction': 'DOWN', }; @@ -75,82 +131,135 @@ function layout( nodes.forEach((n) => { if (n.style && n.style.width && n.style.height) { + if (typeof n.style.width === 'string') { + throw new Error('Unexpeted CSS width'); + } + if (typeof n.style.height === 'string') { + throw new Error('Unexpeted CSS height'); + } n.width = n.style.width; n.height = n.style.height; } }); nodes = nodes.filter((n) => !isHiddenNode(showOnlySelected, navHistory, n)); edges = edges.filter((e) => !isHiddenEdge(showOnlySelected, navHistory, e)); - const groupNodes = nodes - .filter((node) => node.type === 'group') - .map((node) => ({ [node.id]: node })) - .reduce((acc, val) => ({ ...acc, ...val }), {}); - nodes - .filter((node) => node.type !== 'group') - .forEach( - (node) => - (groupNodes[node.parentNode] = { - ...groupNodes[node.parentNode], - children: ( - groupNodes[node.parentNode]?.children || [] - ).concat(node), - }), - ); - const graph = { + type ElkNodeWithChildren = ElkNode & { children: Array }; + + const nodesMap = new Map( + nodes.map((node) => [ + node.id, + { + node, + elkNode: { + id: node.id, + width: node.width ?? undefined, + height: node.height ?? undefined, + children: [], + } as ElkNodeWithChildren, + }, + ]), + ); + + for (const { node, elkNode } of nodesMap.values()) { + if (node.type === 'group') { + continue; + } + if (node.parentNode === undefined) { + return; + } + // Safety: we've just inserted every node from nodes to map + nodesMap.get(node.parentNode)!.elkNode.children.push(elkNode); + } + + // Primitive edges are deprecated in ELK, so we should use ElkExtendedEdge, that use arrays, essentially hyperedges + const elkEdges = edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })); + + const graph: ElkNode = { id: 'root', - layoutOptions: layoutOptions, - children: Object.keys(groupNodes).map((key) => groupNodes[key]), - edges: edges, + layoutOptions, + children: [...nodesMap.values()] + .filter(({ node }) => node.type === 'group') + .map(({ elkNode }) => elkNode), + edges: elkEdges, }; - const elk = new ELK(); - return elk.layout(graph).then(({ children }) => { - // By mutating the children in-place we saves ourselves from creating a - // needless copy of the nodes array. - const flattenChildren = []; - - children.forEach((node) => { - node.position = { x: node.x, y: node.y }; - node.style = { - ...node.style, - width: node.width, - height: node.height, - }; - flattenChildren.push(node); - node.children.forEach((child) => { - child.position = { x: child.x, y: child.y }; - flattenChildren.push(child); - }); - delete node.children; - }); + function elk2flow(elkNode: ElkNode, flatChildren: Array): void { + const nodePair = nodesMap.get(elkNode.id); + if (nodePair === undefined) { + throw new Error('Unexpected node id from ELK'); + } + const node = nodePair.node; - setNodes(flattenChildren); - setEdges(edges); - window.requestAnimationFrame(() => { - if (navHistory?.length) { - setTimeout(() => { - zoomTo(fitView, navHistory); - }, 500); - } else { - fitView(); - } + if (elkNode.x === undefined || elkNode.y === undefined) { + throw new Error('Unexpected position from ELK'); + } + node.position = { x: elkNode.x, y: elkNode.y }; + node.style = { + ...node.style, + width: elkNode.width, + height: elkNode.height, + }; + node.width = elkNode.width ?? null; + node.height = elkNode.height ?? null; + flatChildren.push(node); + (elkNode.children ?? []).forEach((child) => { + elk2flow(child, flatChildren); }); - return flattenChildren; + } + + // TODO add throbber while waiting for layout + // TODO add queue to be able to cancel request before sending it to worker + const { children } = await elk.layout(graph); + + if (abortSignal.aborted) { + return; + } + + // By mutating the children in-place we saves ourselves from creating a + // needless copy of the nodes array. + const flatChildren: Array = []; + + (children ?? []).forEach((elkNode) => { + elk2flow(elkNode, flatChildren); }); + + setNodes(flatChildren); + setEdges(edges); + + if (abortSignal.aborted) { + return; + } + // TODO investigate why setTimeout is necessary, something related to ReactFlow state and setNodes/setEdges probably + setTimeout(() => { + if (abortSignal.aborted) { + return; + } + + zoomTo(fitView, navHistory); + }, 500); + return flatChildren; } const highlightColor = 'rgba(170,255,170,0.71)'; const selectColor = 'rgba(170,187,255,0.71)'; -const zoomTo = (fitView, classId) => { +const zoomTo = (fitView: FitView, classId: Array): void => { if (!classId) { return; } fitView({ duration: 600, nodes: classId.map((id) => ({ id: `c${id}` })) }); }; -function isHiddenNode(showOnlySelected, navHistory, n) { +function isHiddenNode( + showOnlySelected: boolean, + navHistory: NavHistoryState, + n: Node, +): boolean { return ( showOnlySelected && navHistory.indexOf( @@ -159,7 +268,11 @@ function isHiddenNode(showOnlySelected, navHistory, n) { ); } -const nodeStyles = (nodes, navHistory, showOnlySelected) => { +const nodeStyles = ( + nodes: Array, + navHistory: NavHistoryState, + showOnlySelected: boolean, +): Array => { return nodes.map((n) => { return { ...n, @@ -175,7 +288,11 @@ const nodeStyles = (nodes, navHistory, showOnlySelected) => { }); }; -function isHiddenEdge(showOnlySelected, navHistory, e) { +function isHiddenEdge( + showOnlySelected: boolean, + navHistory: NavHistoryState, + e: Edge, +): boolean { return ( showOnlySelected && (navHistory.indexOf(e.source.replace(/^(\d+)(-?).*$/, '$1')) === -1 || @@ -183,7 +300,11 @@ function isHiddenEdge(showOnlySelected, navHistory, e) { ); } -const edgeStyles = (edges, navHistory, showOnlySelected) => { +const edgeStyles = ( + edges: Array, + navHistory: NavHistoryState, + showOnlySelected: boolean, +): Array => { return edges.map((e) => { return { ...e, @@ -192,7 +313,7 @@ const edgeStyles = (edges, navHistory, showOnlySelected) => { }); }; -const splitLabel = (label) => { +const splitLabel = (label: string): Array => { const result = ['']; let isDigit = false; for (let i = 0; i < label.length; i++) { @@ -212,8 +333,9 @@ const splitLabel = (label) => { }; const ChildrenNode = - ({ navigate /*, nodes*/ }) => - ({ data: { label } }) => { + ({ navigate /*, nodes*/ }: { navigate: (id: string) => void }) => + (props: NodeProps) => { + const { label } = props.data; return (
@@ -243,23 +365,34 @@ const ChildrenNode = ); }; +type PreNodesState = { + preNodes: Array; + preEdges: Array; +}; + +function jsonClone(t: T): T { + return JSON.parse(JSON.stringify(t)); +} + +type NavHistoryState = Array; + const LayoutFlow = () => { - const [{ preNodes, preEdges }, setPreNodesEdges] = useState({ + const [{ preNodes, preEdges }, setPreNodesEdges] = useState({ preNodes: initialNodes, preEdges: initialEdges, }); const [nodes, setNodes, onNodesChange] = useNodesState( - JSON.parse(JSON.stringify(initialNodes)), + jsonClone(initialNodes), ); const [edges, setEdges, onEdgesChange] = useEdgesState( - JSON.parse(JSON.stringify(initialEdges)), + jsonClone(initialEdges), ); const [stateIdx, setStateIdx] = useState(0); const { fitView } = useReactFlow(); const [navigateTo, setNavigateTo] = useState(''); - const [navHistory, setNavHistory] = useState([]); - const [showOnlySelected, setShowOnlySelected] = useState(false); + const [navHistory, setNavHistory] = useState([]); + const [showOnlySelected, setShowOnlySelected] = useState(false); const prevState = () => { if (stateIdx === 0) { @@ -268,7 +401,7 @@ const LayoutFlow = () => { let newNodes = preNodes; let newEdges = preEdges; const toRemove = states[stateIdx]; - let toRemoveNodeIds = toRemove.nodes + let toRemoveNodeIds = (toRemove.nodes as Array<{ id: string }>) .concat(toRemove.combos) .map((n) => n.id); let toRemoveEdgeIds = toRemove.edges.map((n) => toEdge(n).id); @@ -280,14 +413,14 @@ const LayoutFlow = () => { newNodes = newNodes.concat( (toRemove.removedNodes || []).map(toRegularNode), ); - const edgeMap = (toRemove.removedEdges || []) + const edgeMap: Record = (toRemove.removedEdges || []) .map(toEdge) .reduce((acc, val) => ({ ...acc, [val.id]: val }), {}); newEdges = newEdges.concat( Object.keys(edgeMap).map((key) => edgeMap[key]), ); const toHighlight = states[stateIdx - 1]; - const toHighlightNodeIds = toHighlight.nodes + const toHighlightNodeIds = (toHighlight.nodes as Array<{ id: string }>) .concat(toHighlight.combos) .map((n) => n.id); newNodes = newNodes.map((n) => ({ @@ -311,7 +444,7 @@ const LayoutFlow = () => { let newEdges = preEdges; setStateIdx(stateIdx + 1); const toAdd = states[stateIdx + 1]; - let toRemoveNodeIds = toAdd.removedNodes + let toRemoveNodeIds = (toAdd.removedNodes as Array<{ id: string }>) .concat(toAdd.removedCombos) .map((n) => n.id); let toRemoveEdgeIds = toAdd.removedEdges.map((n) => toEdge(n).id); @@ -330,7 +463,7 @@ const LayoutFlow = () => { style: { ...n.style, backgroundColor: highlightColor }, })), ); - const edgeMap = (toAdd.edges || []) + const edgeMap: Record = (toAdd.edges || []) .map(toEdge) .reduce((acc, val) => ({ ...acc, [val.id]: val }), {}); newEdges = newEdges.concat( @@ -341,7 +474,7 @@ const LayoutFlow = () => { }; const navigate = useCallback( - (id) => { + (id: string): void => { zoomTo(fitView, [id]); if (!navHistory.includes(id)) { setNavHistory(navHistory.concat(id)); @@ -361,16 +494,21 @@ const LayoutFlow = () => { ); useEffect(() => { + const ac = new AbortController(); + layout( {}, - JSON.parse(JSON.stringify(preNodes)), - JSON.parse(JSON.stringify(preEdges)), + jsonClone(preNodes), + jsonClone(preEdges), setNodes, setEdges, fitView, navHistory, showOnlySelected, + ac.signal, ); + + return () => ac.abort(); }, [ preNodes, setNodes, @@ -459,5 +597,8 @@ function rootComponent() { } const rootElement = document.getElementById('ui'); +if (rootElement === null) { + throw new Error('Root element not found'); +} const root = createRoot(rootElement); root.render(rootComponent()); diff --git a/rust/cubesql/cubesql/egraph-debug-template/tsconfig.json b/rust/cubesql/cubesql/egraph-debug-template/tsconfig.json new file mode 100644 index 0000000000000..4f2a30d1734c8 --- /dev/null +++ b/rust/cubesql/cubesql/egraph-debug-template/tsconfig.json @@ -0,0 +1,33 @@ +{ + // multiple base configs is supported in TS 5.0+ + // See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#supporting-multiple-configuration-files-in-extends + // But CRA supports only TS 4 + // To add insult to injury, is does not fail with proper error, it prints "No issues found." instead + + // @tsconfig/create-react-app is a bit broken, it uses moduleResolution=bundler with resolveJsonModule=true, which is incompatible + // TODO make it ["@tsconfig/create-react-app", "@tsconfig/strictest"] after TS bump + "extends": "@tsconfig/strictest", + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "skipLibCheck": true, + + // Those are from create-react-app + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "NodeNext", + "target": "es2015", + + // Those are from create-react-app + "allowSyntheticDefaultImports": true, + "jsx": "react-jsx", + "noEmit": true, + "resolveJsonModule": true, + + // This adds too much noise when iterating over arrays + "noUncheckedIndexedAccess": false, + }, + "include": [ + "src" + ] +} diff --git a/rust/cubesql/cubesql/src/compile/rewrite/rewriter.rs b/rust/cubesql/cubesql/src/compile/rewrite/rewriter.rs index a69115d74d7c3..70346db658e19 100644 --- a/rust/cubesql/cubesql/src/compile/rewrite/rewriter.rs +++ b/rust/cubesql/cubesql/src/compile/rewrite/rewriter.rs @@ -182,8 +182,12 @@ fn write_debug_states(runner: &CubeRunner, stage: &str) -> Result<(), CubeError> format!("{}/package.json", dir), )?; fs::copy( - "egraph-debug-template/src/index.js", - format!("{}/src/index.js", dir), + "egraph-debug-template/tsconfig.json", + format!("{}/tsconfig.json", dir), + )?; + fs::copy( + "egraph-debug-template/src/index.tsx", + format!("{}/src/index.tsx", dir), )?; let mut states = Vec::new();