diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..8f2e342 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 18.18.0 diff --git a/components/FlowView.tsx b/components/FlowView.tsx index 3ee0ea2..d48ad82 100644 --- a/components/FlowView.tsx +++ b/components/FlowView.tsx @@ -1,7 +1,7 @@ import listTree from "@iconify/icons-gg/list-tree"; import { Icon } from "@iconify/react"; import { ElkNode } from "elkjs/lib/elk.bundled"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import ReactFlow, { applyNodeChanges, Background, @@ -16,8 +16,8 @@ import ReactFlow, { import EnumNode from "~/components/EnumNode"; import ModelNode from "~/components/ModelNode"; import RelationEdge from "~/components/RelationEdge"; -import { dmmfToElements } from "~/util/dmmfToElements"; import { getLayout } from "~/util/layout"; +import { generateFlowFromDMMF } from "~/util/prismaToFlow"; import { DMMFToElementsResult } from "~/util/types"; import type { DMMF } from "@prisma/generator-helper"; @@ -32,33 +32,30 @@ const edgeTypes = { }; const FlowView = ({ dmmf }: FlowViewProps) => { - // TODO: move to controlled nodes/edges, and change this to generate a NodeChanges[] as a diff so that positions gets preserved. - // Will be more complex but gives us better control over how they're handled, and makes storing locations EZ. - // https://reactflow.dev/docs/guides/migrate-to-v10/#11-controlled-nodes-and-edges - const [layout, setLayout] = useState(null); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); - useEffect(() => { - const { nodes, edges } = dmmf - ? dmmfToElements(dmmf, layout) + const regenerateNodes = (layout: ElkNode | null) => { + const { nodes: newNodes, edges: newEdges } = dmmf + ? generateFlowFromDMMF(dmmf, nodes, layout) : ({ nodes: [], edges: [] } as DMMFToElementsResult); // See if `applyNodeChanges` can work here? - setNodes(nodes); - setEdges(edges); - }, [dmmf, layout]); + setNodes(newNodes); + setEdges(newEdges); + }; - const refreshLayout = useCallback(async () => { + const refreshLayout = async () => { const layout = await getLayout(nodes, edges); - setLayout(layout); - }, [nodes, edges]); + regenerateNodes(layout); + }; - const onNodesChange: OnNodesChange = useCallback( - (changes) => - setNodes((nodes) => applyNodeChanges(changes, nodes as any) as any), - [setNodes], - ); + const onNodesChange: OnNodesChange = (changes) => + setNodes((nodes) => applyNodeChanges(changes, nodes as any) as any); + + useEffect(() => { + regenerateNodes(null); + }, [dmmf]); return ( <> diff --git a/components/ModelNode.tsx b/components/ModelNode.tsx index c99e9e7..b0741d5 100644 --- a/components/ModelNode.tsx +++ b/components/ModelNode.tsx @@ -4,27 +4,16 @@ import { Handle, Position, useReactFlow, useStoreApi } from "reactflow"; import styles from "./Node.module.scss"; +import { + enumEdgeTargetHandleId, + relationEdgeSourceHandleId, + relationEdgeTargetHandleId, +} from "~/util/prismaToFlow"; import { ModelNodeData } from "~/util/types"; type ColumnData = ModelNodeData["columns"][number]; -const isTarget = ({ - kind, - isList, - relationFromFields, - relationName, - relationType, -}: ColumnData) => - kind === "enum" || - ((relationType === "1-n" || relationType === "m-n") && !isList) || - (relationType === "1-1" && !relationFromFields?.length) || - // Fallback for implicit m-n tables (maybe they should act like the child in a - // 1-n instead) - (kind === "scalar" && !!relationName); - -const isSource = ({ isList, relationFromFields, relationType }: ColumnData) => - ((relationType === "1-n" || relationType === "m-n") && isList) || - (relationType === "1-1" && !!relationFromFields?.length); +const isRelationed = ({ relationData }: ColumnData) => !!relationData?.side; const ModelNode = ({ data }: ModelNodeProps) => { const store = useStoreApi(); @@ -68,55 +57,89 @@ const ModelNode = ({ data }: ModelNodeProps) => { - {data.columns.map((col) => ( - - - - - - {col.displayType} - - -
- {col.defaultValue || ""} - {isSource(col) && ( - - )} -
- - - ))} + return ( + + + + + + {col.displayType} + + +
+ {col.defaultValue || ""} + {sourceHandle} +
+ + + ); + })} ); diff --git a/pages/index.tsx b/pages/index.tsx index 4e47bd5..0bbd24d 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -92,7 +92,7 @@ const IndexPage = () => { })); const [model] = monaco.editor.getModels(); - monaco.editor.setModelMarkers(model, "prismaliser", markers); + monaco.editor.setModelMarkers(model!, "prismaliser", markers); }, [monaco, schemaErrors]); useEffect(() => { diff --git a/tsconfig.json b/tsconfig.json index b07accc..246ac6b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -16,21 +12,14 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, + "noUncheckedIndexedAccess": true, "jsx": "preserve", "baseUrl": ".", "paths": { - "~/*": [ - "./*" - ] + "~/*": ["./*"] }, "incremental": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx" - ], - "exclude": [ - "node_modules" - ] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] } diff --git a/util/dmmfToElements.ts b/util/dmmfToElements.ts deleted file mode 100644 index b74e0ae..0000000 --- a/util/dmmfToElements.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { ElkNode } from "elkjs"; -import { groupBy } from "rambda"; -import { Edge, Node } from "reactflow"; - -import { - EnumNodeData, - DMMFToElementsResult, - ModelNodeData, - RelationType, -} from "./types"; - -import type { DMMF } from "@prisma/generator-helper"; - -type FieldWithTable = DMMF.Field & { tableName: string }; -interface Relation { - type: RelationType; - fields: readonly FieldWithTable[]; -} - -const letters = ["A", "B"]; - -const generateEnumNode = ( - { name, dbName, documentation, values }: DMMF.DatamodelEnum, - layout: ElkNode | null, -): Node => { - const positionedNode = layout?.children?.find( - (layoutNode) => layoutNode.id === name, - ); - - return { - id: name, - type: "enum", - position: { x: positionedNode?.x || 0, y: positionedNode?.y || 0 }, - width: positionedNode?.width, - height: positionedNode?.height, - data: { - type: "enum", - name, - dbName, - documentation, - values: values.map(({ name }) => name), - }, - }; -}; - -const generateModelNode = ( - { name, dbName, documentation, fields }: DMMF.Model, - relations: Readonly>, - layout: ElkNode | null, -): Node => { - const positionedNode = layout?.children?.find( - (layoutNode) => layoutNode.id === name, - ); - - return { - id: name, - type: "model", - position: { x: positionedNode?.x || 250, y: positionedNode?.y || 25 }, - data: { - type: "model", - name, - dbName, - documentation, - columns: fields.map( - ({ - name, - type, - kind, - documentation, - isList, - relationName, - relationFromFields, - relationToFields, - isRequired, - hasDefaultValue, - default: def, - }) => ({ - name, - kind, - documentation, - isList, - isRequired, - relationName, - relationFromFields, - relationToFields, - relationType: ( - (relationName && relations[relationName]) as Relation | undefined - )?.type, - // `isList` and `isRequired` are mutually exclusive as per the spec - displayType: type + (isList ? "[]" : !isRequired ? "?" : ""), - type, - defaultValue: - !hasDefaultValue || def === undefined - ? null - : typeof def === "object" && "name" in def - ? `${def.name}(${def.args - .map((arg) => JSON.stringify(arg)) - .join(",")})` - : kind === "enum" - ? def.toString() - : JSON.stringify(def), - }), - ), - }, - }; -}; - -const generateEnumEdge = (col: FieldWithTable): Edge => ({ - id: `e${col.tableName}-${col.name}-${col.type}`, - source: col.type, - target: col.tableName, - type: "smoothstep", - sourceHandle: col.type, - targetHandle: `${col.tableName}-${col.name}`, -}); - -const generateRelationEdge = ([relationName, { type, fields }]: [ - string, - Relation, -]): Edge[] => { - const base = { - id: `e${relationName}`, - type: "relation", - label: relationName, - data: { relationType: type }, - }; - - if (type === "m-n") - return fields.map((col, i) => ({ - ...base, - id: `e${relationName}-${col.tableName}-${col.type}`, - source: col.tableName, - target: `_${relationName}`, - sourceHandle: `${col.tableName}-${col.relationName}-${col.name}`, - targetHandle: `_${relationName}-${letters[i]}`, - })); - else if (type === "1-n") { - const source = fields.find((x) => x.isList)!; - - return [ - { - ...base, - source: source.tableName, - target: source.type, - sourceHandle: `${source.tableName}-${relationName}-${source.name}`, - targetHandle: `${source.type}-${relationName}`, - }, - ]; - } else - return [ - { - ...base, - source: fields[0].tableName, - target: fields[0].type, - sourceHandle: `${fields[0].tableName}-${relationName}-${fields[0].name}`, - targetHandle: `${fields[0].type}-${relationName}`, - }, - ]; -}; - -// TODO: renaming relations sometimes makes the edge disappear. Might be a memo -// issue, need to look into it a bit better at some point. -export const dmmfToElements = ( - data: DMMF.Datamodel, - layout: ElkNode | null, -): DMMFToElementsResult => { - const filterFields = (kind: DMMF.FieldKind) => - data.models.flatMap(({ name: tableName, fields }) => - fields - .filter((col) => col.kind === kind) - .map((col) => ({ ...col, tableName })), - ); - - const relationFields = filterFields("object"); - const enumFields = filterFields("enum"); - - // `pipe` typing broke so I have to do this for now. Reeeeaaaally fucking need - // that pipeline operator. - const intermediate1: Readonly> = - groupBy((col) => col.relationName!, relationFields); - const intermediate2: ReadonlyArray<[string, Relation]> = Object.entries( - intermediate1, - ).map(([key, [one, two]]) => { - if (one.isList && two.isList) - return [key, { type: "m-n", fields: [one, two] }]; - else if (one.isList || two.isList) - return [key, { type: "1-n", fields: [one, two] }]; - else return [key, { type: "1-1", fields: [one, two] }]; - }); - const relations: Readonly> = - Object.fromEntries(intermediate2); - - const implicitManyToMany = Object.entries(relations) - .filter(([, { type }]) => type === "m-n") - .map( - ([relationName, { fields }]) => - ({ - name: `_${relationName}`, - dbName: null, - fields: fields.map((field, i) => ({ - name: letters[i], - kind: "scalar", - isList: false, - isRequired: true, - // CBA to fuck with some other shit in the ModelNode, so this is a - // "hack" to get the corresponding letter on the handle ID. In the - // future it'd probably be a better idea to make __ALL__ handles - // take the shape of `table-columnName-relationName/foreignName`???? - relationName: letters[i], - hasDefaultValue: false, - // this is gonna break on composite ids i think lol - type: data.models - .find((m) => m.name === field.type) - ?.fields.find((x) => x.isId)?.type, - })), - }) as DMMF.Model, - ); - - // TODO: looks like the handle ids are incorrect, and also in the wrong spot. need to find out why. - const x = { - nodes: [ - ...data.enums.map((enumData) => generateEnumNode(enumData, layout)), - ...[...data.models, ...implicitManyToMany].map((model) => - generateModelNode(model, relations, layout), - ), - ], - edges: [ - ...enumFields.map(generateEnumEdge), - ...Object.entries(relations).flatMap(generateRelationEdge), - ], - }; - console.log(x); - return x; -}; diff --git a/util/index.ts b/util/index.ts index 6d0be23..a5ea88e 100644 --- a/util/index.ts +++ b/util/index.ts @@ -8,7 +8,7 @@ export const parseDMMFError = (error: string): SchemaError[] => .split("error: ") .slice(1) .map((msg) => msg.match(errRegex)!.slice(1)) - .map(([reason, row]) => ({ reason, row })); + .map(([reason, row]) => ({ reason: reason!, row: row! })); export const toUrlSafeB64 = (input: string) => btoa(input).replace(/\//g, "_").replace(/\+/g, "-"); diff --git a/util/prismaToFlow.ts b/util/prismaToFlow.ts new file mode 100644 index 0000000..a375052 --- /dev/null +++ b/util/prismaToFlow.ts @@ -0,0 +1,406 @@ +import { DMMF } from "@prisma/generator-helper"; +import { ElkNode } from "elkjs"; +import { + concat, + count, + filter, + groupBy, + map, + mergeWith, + pick, + reduce, +} from "rambda"; +import { Edge, Node } from "reactflow"; + +import { + DMMFToElementsResult, + EnumNodeData, + ModelNodeData, + ModelRelationData, + RelationEdgeData, + RelationSide, + RelationType, +} from "./types"; + +const letters = ["A", "B"]; + +/** + * Entrypoint into creating a React Flow network from the Prisma datamodel. + */ +export const generateFlowFromDMMF = ( + datamodel: DMMF.Datamodel, + previousNodes: Array | Node>, + layout: ElkNode | null, +): DMMFToElementsResult => { + const modelRelations = getModelRelations(datamodel); + const enumRelations = getEnumRelations(datamodel); + const nodeData = generateNodes(datamodel, modelRelations); + + const nodes = positionNodes(nodeData, previousNodes, layout); + const edges = relationsToEdges(modelRelations, enumRelations); + + return { + nodes, + edges, + }; +}; + +const relationType = (listCount: number): RelationType => + listCount > 1 ? "m-n" : listCount === 1 ? "1-n" : "1-1"; + +const relationSide = (field: DMMF.Field): RelationSide => + // `source` owns the relation in the schema + field.relationFromFields?.length || field.relationToFields?.length + ? "source" + : "target"; + +// Functions for various IDs so that consistency is ensured across all parts of +// the app easily. +export const edgeId = (target: string, source: string, targetColumn: string) => + `edge-${target}-${targetColumn}-${source}`; + +export const enumEdgeTargetHandleId = (table: string, column: string) => + `${table}-${column}`; + +const implicitManyToManyModelNodeId = (relation: string) => `_${relation}`; + +export const relationEdgeSourceHandleId = ( + table: string, + relation: string, + column: string, +) => `${table}-${relation}-${column}`; + +// TODO: might need to include column name for multiple relations of same type?? +export const relationEdgeTargetHandleId = (table: string, relation: string) => + `${table}-${relation}`; + +const virtualTableName = (relation: string, table: string) => + `${relation}-${table}`; + +interface GotModelRelations { + name: string; + dbName?: string; + type: RelationType; + virtual?: { + name: string; + field: { + name: string; + type: string; + }; + }; + fields: Array<{ + name: string; + tableName: string; + side: RelationSide; + type: string; + }>; +} + +/** + * Filter through a schema to find all the models that are part of a + * relationship, as well as what side of the relationships they are on. + */ +const getModelRelations = ({ + models, +}: DMMF.Datamodel): Record => { + const groupedRelations: Record< + string, + Array + > = filter( + (_: any, prop: string) => prop !== "undefined", + // Match both ends of relation together, and collapse everything into the + // same object. (relation names should be unique so this is safe). + reduce( + mergeWith(concat), + {}, + models.map((m) => + // Create a object mapping `relationName: field[]`. + groupBy( + (f) => f.relationName!, + m.fields + // Don't bother processing any fields that aren't part of a relationship. + .filter((f) => f.relationName) + .map((f) => ({ ...f, tableName: m.name })), + ), + ), + ), + ); + + const output = map((fields, key) => { + const listCount = count((f) => f.isList, fields); + const type = relationType(listCount); + + return { + name: key, + type, + fields: fields.map((f) => ({ + name: f.name, + tableName: f.tableName, + side: relationSide(f), + type: f.type, + })), + }; + }, groupedRelations); + + const withVirtuals = Object.values(output).reduce< + Record + >((acc, curr) => { + if (curr.type === "m-n") + for (const [i, field] of curr.fields.entries()) { + const newName = virtualTableName(curr.name, field.tableName); + const virtualLetter = letters[i]!; + + acc[newName] = { + name: newName, + dbName: curr.name, + type: "1-n", + virtual: { + name: implicitManyToManyModelNodeId(curr.name), + field: { name: virtualLetter, type: field.tableName }, + }, + // Reuse current field straight up because they're always `target`. + fields: [ + field, + { + name: virtualLetter, + tableName: implicitManyToManyModelNodeId(curr.name), + side: "source", + type: field.tableName, + }, + ], + }; + } + else acc[curr.name] = curr; + + return acc; + }, {}); + + return withVirtuals; +}; + +/** + * Filter through a schema to find all the models that refer to a defined Enum. + */ +const getEnumRelations = ({ models }: DMMF.Datamodel) => + models + .map((m) => { + const fields = m.fields.filter((f) => f.kind === "enum"); + const relations = fields.map((f) => ({ + enum: f.type, + column: f.name, + })); + + return { + name: m.name, + relations, + }; + }) + .filter((m) => m.relations.length); + +/** + * Map found relationships into React Flow edges. + */ +const relationsToEdges = ( + modelRelations: ReturnType, + enumRelations: ReturnType, +): Array> => { + let result: Array> = []; + + // Enum edges are dead shrimple + for (const rel of enumRelations) { + const edges = rel.relations.map( + (r): Edge => ({ + id: edgeId(rel.name, r.enum, r.column), + type: "smoothstep", + source: r.enum, + target: rel.name, + sourceHandle: r.enum, + targetHandle: enumEdgeTargetHandleId(rel.name, r.column), + }), + ); + + result = result.concat(edges); + } + + for (const rel of Object.values(modelRelations)) { + const base = { + id: `edge-${rel.name}`, + type: "relation", + label: rel.name, + data: { relationType: rel.type }, + }; + + const source = rel.fields.find((f) => f.side === "source")!; + const target = rel.fields.find((f) => f.side === "target")!; + + result.push({ + ...base, + source: source.tableName, + target: target.tableName, + sourceHandle: relationEdgeSourceHandleId( + source.tableName, + rel.name, + source.name, + ), + targetHandle: relationEdgeTargetHandleId(target.tableName, rel.name), + }); + // } + } + + return result; +}; + +/** + * Map a Prisma datamodel into React Flow node data. + * Does not generate position data. + */ +const generateNodes = ( + { enums, models }: DMMF.Datamodel, + relations: Record, +) => { + let nodes = [] as Array; + + nodes = nodes.concat(generateModelNodes(models, relations)); + nodes = nodes.concat(generateImplicitModelNodes(relations)); + nodes = nodes.concat(generateEnumNodes(enums)); + + return nodes; +}; + +const generateEnumNodes = (enums: DMMF.DatamodelEnum[]): EnumNodeData[] => + enums.map(({ name, dbName, documentation, values }) => ({ + type: "enum", + name, + dbName, + documentation, + values: values.map(({ name }) => name), + })); + +const generateModelNodes = ( + models: DMMF.Model[], + relations: Record, +): ModelNodeData[] => + models.map(({ name, dbName, documentation, fields }) => { + const columns: ModelNodeData["columns"] = fields.map((f) => { + // `isList` and `isRequired` are mutually exclusive as per the spec + const displayType = f.type + (f.isList ? "[]" : !f.isRequired ? "?" : ""); + let defaultValue: string | null = null; + + if (f.hasDefaultValue && f.default !== undefined) + if (typeof f.default === "object" && "name" in f.default) + // Column has a function style default + defaultValue = `${f.default.name}(${f.default.args + .map((arg) => JSON.stringify(arg)) + .join(",")})`; + // Enums only have a scalar as default value + else if (f.kind === "enum") defaultValue = f.default.toString(); + else defaultValue = JSON.stringify(f.default); + + const relData = + relations[f.relationName!] || + relations[virtualTableName(f.relationName!, name)]; + const thisRel = relData?.fields.find( + (g) => g.name === f.name && g.tableName === name, + ); + + const relationData: ModelRelationData | null = relData + ? { + name: relData.name, + type: relData.type, + // If we can't find the matching field, sucks to suck I guess. + side: thisRel?.side || ("" as any), + } + : null; + + return { + ...pick( + ["name", "kind", "documentation", "isList", "isRequired", "type"], + f, + ), + displayType, + defaultValue, + relationData, + }; + }); + + return { + type: "model", + name, + dbName, + documentation, + columns, + }; + }); + +/** + * Generates intermediary tables to represent how implicit many-to-many + * relationships work under the hood (mostly because I'm too lazy to distinguish + * between implicit and explicit). + */ +const generateImplicitModelNodes = ( + relations: Record, +): ModelNodeData[] => { + const hasVirtuals = Object.values(relations).filter((rel) => rel.virtual); + const grouped = map( + (rel: GotModelRelations[]) => { + const fields = rel.map((r) => r.virtual!.field); + return { relationName: rel[0]!.dbName!, fields }; + }, + groupBy((rel) => rel.virtual!.name, hasVirtuals), + ); + + return Object.entries(grouped).map(([name, { relationName, fields }]) => { + const columns: ModelNodeData["columns"] = fields.map((col, i) => ({ + name: letters[i]!, + kind: "scalar", + isList: false, + isRequired: true, + defaultValue: null, + type: col.type, + displayType: col.type, + relationData: { + name: virtualTableName(relationName, col.type), + side: "source", + type: "1-n", + }, + })); + + return { + type: "model", + name, + dbName: null, + columns, + }; + }); +}; + +/** + * Takes in plain React Flow node data and positions them either based on an Elk + * reflow, previous layout state, or with fresh positions. + */ +const positionNodes = ( + nodeData: Array, + previousNodes: Array | Node>, + layout: ElkNode | null, +): Array | Node> => + nodeData.map((n) => { + const positionedNode = layout?.children?.find( + (layoutNode) => layoutNode.id === n.name, + ); + console.log(positionedNode, n.name); + const previousNode = previousNodes.find((prev) => prev.id === n.name); + + return { + id: n.name, + type: n.type, + position: { + x: positionedNode?.x ?? previousNode?.position.x ?? 0, + y: positionedNode?.y ?? previousNode?.position.y ?? 0, + }, + width: previousNode?.width ?? 0, + height: previousNode?.height ?? 0, + // Shhhhh + // TODO: fix types to not need cast + data: n as any, + }; + }); diff --git a/util/types.ts b/util/types.ts index 5546819..840fc3d 100644 --- a/util/types.ts +++ b/util/types.ts @@ -1,12 +1,19 @@ import { Edge, Node } from "reactflow"; export type RelationType = "1-1" | "1-n" | "m-n"; +export type RelationSide = "source" | "target"; export interface SchemaError { reason: string; row: string; } +export interface ModelRelationData { + side: RelationSide; + type: RelationType; + name: string; +} + export interface EnumNodeData { type: "enum"; name: string; @@ -28,11 +35,8 @@ export interface ModelNodeData { documentation?: string; isList: boolean; isRequired: boolean; - relationName?: string | null; - relationFromFields?: string[] | null; - relationToFields?: string[] | null; defaultValue?: string | null; - relationType?: RelationType | null; + relationData: ModelRelationData | null; }>; }