Skip to content

Commit

Permalink
Adds code to output mermaid graph for query graphs (#2779)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sylvain Lebresne authored Sep 14, 2023
1 parent 621b93c commit a26975a
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 10 deletions.
6 changes: 6 additions & 0 deletions .changeset/chilled-geckos-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---

---



1 change: 1 addition & 0 deletions query-graphs-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './transition';
export * from './pathContext';
export * from './conditionsCaching';
export * from './conditionsValidation';
export * from './mermaid';
116 changes: 116 additions & 0 deletions query-graphs-js/src/mermaid.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();

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');
}
}
32 changes: 22 additions & 10 deletions query-graphs-js/src/querygraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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]);
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<any> | NamedType, directiveFct: (metadata: FederationMetadata) => DirectiveDefinition): boolean {
Expand Down

0 comments on commit a26975a

Please sign in to comment.