From a26975a2d36721c860e6f2d67839e5209fb7df89 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Thu, 14 Sep 2023 10:32:47 +0200 Subject: [PATCH] Adds code to output mermaid graph for query graphs (#2779) --- .changeset/chilled-geckos-change.md | 6 ++ query-graphs-js/src/index.ts | 1 + query-graphs-js/src/mermaid.ts | 116 ++++++++++++++++++++++++++++ query-graphs-js/src/querygraph.ts | 32 +++++--- 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 .changeset/chilled-geckos-change.md create mode 100644 query-graphs-js/src/mermaid.ts diff --git a/.changeset/chilled-geckos-change.md b/.changeset/chilled-geckos-change.md new file mode 100644 index 000000000..f0184f387 --- /dev/null +++ b/.changeset/chilled-geckos-change.md @@ -0,0 +1,6 @@ +--- + +--- + + + \ No newline at end of file diff --git a/query-graphs-js/src/index.ts b/query-graphs-js/src/index.ts index 3907233a7..e4ee41a36 100644 --- a/query-graphs-js/src/index.ts +++ b/query-graphs-js/src/index.ts @@ -6,3 +6,4 @@ export * from './transition'; export * from './pathContext'; export * from './conditionsCaching'; export * from './conditionsValidation'; +export * from './mermaid'; diff --git a/query-graphs-js/src/mermaid.ts b/query-graphs-js/src/mermaid.ts new file mode 100644 index 000000000..6eb5aa62d --- /dev/null +++ b/query-graphs-js/src/mermaid.ts @@ -0,0 +1,116 @@ +/* Functions used to output query graphs as [mermaid graphs](https://mermaid.js.org/syntax/flowchart.html). */ + +import { ObjectType } from "@apollo/federation-internals"; +import { Edge, FEDERATED_GRAPH_ROOT_SOURCE, QueryGraph, Vertex, isFederatedGraphRootType, simpleTraversal } from "./querygraph"; + +export type MermaidOptions = { + includeRootTypeLinks?: boolean, +} + +export class MermaidGraph { + private readonly before: string[] = []; + private readonly after: string[] = []; + private readonly subgraphs = new Map(); + + private isBuilt = false; + + constructor( + private readonly graph: QueryGraph, + private readonly options: MermaidOptions = {}, + ) { + for (const name of graph.sources.keys()) { + if (name === this.graph.name || name === FEDERATED_GRAPH_ROOT_SOURCE) { + continue; + } + this.subgraphs.set(name, []); + } + } + + private subgraphName(vertex: Vertex): string | undefined { + if (vertex.source === this.graph.name || vertex.source === FEDERATED_GRAPH_ROOT_SOURCE) { + return undefined; + } + return vertex.source; + } + + private vertexName(vertex: Vertex): string { + if (isFederatedGraphRootType(vertex.type)) { + return `root-${vertex.type.name.slice(1, vertex.type.name.length-1)}`; + } + const sg = this.subgraphName(vertex); + const n = sg ? `${vertex.type.name}-${sg}` : `${vertex.type.name}`; + return vertex.provideId ? `${n}-${vertex.provideId}` : n; + } + + addVertex(vertex: Vertex): void { + const sg = this.subgraphName(vertex); + const addTo = sg ? this.subgraphs.get(sg)! : this.before; + if (isFederatedGraphRootType(vertex.type)) { + addTo.push(`${this.vertexName(vertex)}(["root(${vertex.type.name.slice(1, vertex.type.name.length)})"])`); + } else { + addTo.push(`${this.vertexName(vertex)}["${vertex.toString()}"]`); + } + } + + addEdge(edge: Edge): boolean { + switch (edge.transition.kind) { + case 'FieldCollection': + if (edge.transition.definition.name.startsWith('_')) { + return false; + } + break; + case 'RootTypeResolution': + if (!(this.options.includeRootTypeLinks ?? true)) { + return false; + } + break; + case 'SubgraphEnteringTransition': + const rt = edge.tail.type as ObjectType; + if (rt.fields().filter((f) => !f.name.startsWith('_')).length === 0) { + return false; + } + break; + } + + const head = this.vertexName(edge.head); + const tail = this.vertexName(edge.tail); + const addTo = edge.head.source !== this.graph.name && edge.head.source === edge.tail.source + ? this.subgraphs.get(edge.head.source)! + : this.after; + const label = edge.label(); + if (label.length === 0) { + addTo.push(`${head} --> ${tail}`); + } else { + addTo.push(`${head} -->|"${label}"| ${tail}`); + } + return true; + } + + build(): void { + if (this.isBuilt) { + return; + } + + simpleTraversal( + this.graph, + (v) => this.addVertex(v), + (e) => this.addEdge(e), + ); + + this.isBuilt = true; + } + + toString(): string { + this.build(); + + const final = [ 'flowchart TD' ]; + this.before.forEach((b) => final.push(' ' + b)); + for (const [name, data] of this.subgraphs.entries()) { + final.push(` subgraph ${name}`); + data.forEach((d) => final.push(' ' + d)); + final.push(' end'); + } + this.after.forEach((a) => final.push(' ' + a)); + return final.join('\n'); + } +} diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 7fc88525c..2a890c792 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -61,6 +61,13 @@ export function isFederatedGraphRootType(type: NamedType) { */ export class Vertex { hasReachableCrossSubgraphEdges: boolean = false; + // @provides works by creating duplicates of the vertex/type involved in the provides and adding the provided + // edges only to those copy. This means that with @provides, you can have more than one vertex per-type-and-subgraph + // in a query graph. Which is fined, but this `provideId` allows to distinguish if a vertex was created as part of + // this @provides duplication or not. The value of this field has no other meaning than to be unique per-@provide, + // and so all the vertex copied for a given @provides application will have the same `provideId`. Overall, this + // mostly exists for debugging visualization. + provideId: number | undefined; constructor( /** Index used for this vertex in the query graph it is part of. */ @@ -75,7 +82,8 @@ export class Vertex { ) {} toString(): string { - return `${this.type}(${this.source})`; + const label = `${this.type}(${this.source})`; + return this.provideId ? `${label}-${this.provideId}` : label; } } @@ -741,6 +749,7 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr ); } // Now we handle @provides + let provideId = 0; for (const [i, subgraph] of subgraphs.entries()) { const subgraphSchema = schemas[i]; const subgraphMetadata = federationMetadata(subgraphSchema); @@ -756,6 +765,7 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr const field = e.transition.definition; assert(isCompositeType(type), () => `Non composite type "${type}" should not have field collection edge ${e}`); for (const providesApplication of field.appliedDirectivesOf(providesDirective)) { + ++provideId; const fieldType = baseType(field.type!); assert(isCompositeType(fieldType), () => `Invalid @provide on field "${field}" whose type "${fieldType}" is not a composite type`) const provided = parseFieldSetArgument({ parentType: fieldType, directive: providesApplication }); @@ -766,9 +776,9 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr const copiedEdge = builder.edge(head, e.index); // We make a copy of the `fieldType` vertex (with all the same edges), and we change this particular edge to point to the // new copy. From that, we can add all the provides edges to the copy. - const copiedTail = builder.makeCopy(tail); + const copiedTail = builder.makeCopy(tail, provideId); builder.updateEdgeTail(copiedEdge, copiedTail); - addProvidesEdges(subgraphSchema, builder, copiedTail, provided); + addProvidesEdges(subgraphSchema, builder, copiedTail, provided, provideId); } } return true; // Always traverse edges @@ -832,7 +842,7 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr return builder.build(FEDERATED_GRAPH_ROOT_SOURCE); } -function addProvidesEdges(schema: Schema, builder: GraphBuilder, from: Vertex, provided: SelectionSet) { +function addProvidesEdges(schema: Schema, builder: GraphBuilder, from: Vertex, provided: SelectionSet, provideId: number) { const stack: [Vertex, SelectionSet][] = [[from, provided]]; const source = from.source; while (stack.length > 0) { @@ -847,7 +857,7 @@ function addProvidesEdges(schema: Schema, builder: GraphBuilder, from: Vertex, p // If this is a leaf field, then we don't really have anything to do. Otherwise, we need to copy // the tail and continue propagating the provides from there. if (selection.selectionSet) { - const copiedTail = builder.makeCopy(existingEdge.tail); + const copiedTail = builder.makeCopy(existingEdge.tail, provideId); builder.updateEdgeTail(existingEdge, copiedTail); stack.push([copiedTail, selection.selectionSet]); } @@ -860,7 +870,7 @@ function addProvidesEdges(schema: Schema, builder: GraphBuilder, from: Vertex, p // If the field is a leaf, then just create the new edge and we're done. Othewise, we // should copy the vertex (unless we just created it), add the edge and continue. if (selection.selectionSet) { - const copiedTail = existingTail ? builder.makeCopy(existingTail) : newTail; + const copiedTail = existingTail ? builder.makeCopy(existingTail, provideId) : newTail; builder.addEdge(v, copiedTail, new FieldCollection(fieldDef, true)); stack.push([copiedTail, selection.selectionSet]); } else { @@ -875,7 +885,7 @@ function addProvidesEdges(schema: Schema, builder: GraphBuilder, from: Vertex, p // @provides shouldn't have validated in the first place (another way to put it is, contrary to fields, there is no way currently // to mark a full type as @external). assert(existingEdge, () => `Shouldn't have ${selection} with no corresponding edge on ${v} (edges are: [${builder.edges(v)}])`); - const copiedTail = builder.makeCopy(existingEdge.tail); + const copiedTail = builder.makeCopy(existingEdge.tail, provideId); builder.updateEdgeTail(existingEdge, copiedTail); stack.push([copiedTail, selection.selectionSet!]); } else { @@ -1030,10 +1040,13 @@ class GraphBuilder { * Creates a new vertex that is a full copy of the provided one, including having the same out-edge, but with no incoming edges. * * @param vertex - the vertex to copy. + * @param provideId - if the vertex is copied for the sake of a `@provides`, an id that identify that provide and will be set on + * the newly copied vertex. * @returns the newly created copy. */ - makeCopy(vertex: Vertex): Vertex { + makeCopy(vertex: Vertex, provideId?: number): Vertex { const newVertex = this.createNewVertex(vertex.type, vertex.source, this.sources.get(vertex.source)!); + newVertex.provideId = provideId; newVertex.hasReachableCrossSubgraphEdges = vertex.hasReachableCrossSubgraphEdges; for (const edge of this.outEdges[vertex.index]) { this.addEdge(newVertex, edge.tail, edge.transition, edge.conditions); @@ -1092,8 +1105,7 @@ class GraphBuilderFromSchema extends GraphBuilder { private readonly supergraph?: { apiSchema: Schema, isFed1: boolean }, ) { super(); - this.isFederatedSubgraph = isFederationSubgraphSchema(schema); - assert(!this.isFederatedSubgraph || supergraph, `Missing supergraph schema for building the federated subgraph graph`); + this.isFederatedSubgraph = !!supergraph && isFederationSubgraphSchema(schema); } private hasDirective(elt: FieldDefinition | NamedType, directiveFct: (metadata: FederationMetadata) => DirectiveDefinition): boolean {