From 38546cb37471d36e1e61cccd251950c6787178fa Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Thu, 9 Nov 2023 09:05:10 +1100 Subject: [PATCH] refactor: completely rewrite Flow generator from scratch Fixes some bugs that existed around table order in the schema with relationships, and now also takes previous layout into account when editing existing tables. Closes #63 and #45 --- .tool-versions | 1 + components/FlowView.tsx | 37 ++-- components/ModelNode.tsx | 153 ++++++++------- pages/index.tsx | 2 +- tsconfig.json | 23 +-- util/dmmfToElements.ts | 234 ---------------------- util/index.ts | 2 +- util/prismaToFlow.ts | 406 +++++++++++++++++++++++++++++++++++++++ util/types.ts | 12 +- 9 files changed, 528 insertions(+), 342 deletions(-) create mode 100644 .tool-versions delete mode 100644 util/dmmfToElements.ts create mode 100644 util/prismaToFlow.ts 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; }>; }