diff --git a/.gitignore b/.gitignore index fc5886e..217ae79 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules dist test-report.xml coverage/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e59f21..2e61680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v3.1.0] - 2023-11-09 + +- Added a new `FroidSchema` class to test the next version of FROID schema + generation. +- FROID schema is now sorted (both new and old version) and include + documentation string. + ## [v3.0.1] - 2023-08-17 ### Fix diff --git a/package.json b/package.json index 63c0c54..08d9195 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wayfair/node-froid", - "version": "3.0.1", + "version": "3.1.0", "description": "Federated GQL Relay Object Identification implementation", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 37a7033..0d5f977 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,6 @@ export { generateFroidSchema, FederationVersion, } from './schema/generateFroidSchema'; +export {FroidSchema} from './schema/FroidSchema'; export * from './service/types'; export * from './schema/types'; diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts new file mode 100644 index 0000000..0832483 --- /dev/null +++ b/src/schema/FroidSchema.ts @@ -0,0 +1,828 @@ +import { + ASTNode, + ConstArgumentNode, + ConstDirectiveNode, + DefinitionNode, + DocumentNode, + EnumTypeDefinitionNode, + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + Kind, + ObjectTypeDefinitionNode, + ScalarTypeDefinitionNode, + SchemaExtensionNode, + StringValueNode, + parse, + print, + specifiedScalarTypes, +} from 'graphql'; +import {ObjectTypeNode} from './types'; +import { + CONTRACT_DIRECTIVE_NAME, + DEFAULT_FEDERATION_LINK_IMPORTS, + DirectiveName, + FED2_OPT_IN_URL, + FED2_VERSION_PREFIX, + ID_FIELD_NAME, + ID_FIELD_TYPE, +} from './constants'; +import assert from 'assert'; +import {Key} from './Key'; +import {KeyField} from './KeyField'; +import {ObjectType} from './ObjectType'; +import {sortDocumentAst} from './sortDocumentAst'; + +type SupportedFroidReturnTypes = + | ScalarTypeDefinitionNode + | EnumTypeDefinitionNode; + +export type KeySorter = (keys: Key[], node: ObjectTypeNode) => Key[]; +export type NodeQualifier = ( + node: ASTNode, + objectTypes: ObjectTypeNode[] +) => boolean; + +export type FroidSchemaOptions = { + contractTags?: string[]; + keySorter?: KeySorter; + nodeQualifier?: NodeQualifier; + typeExceptions?: string[]; +}; + +/** + * The default key sorter + * This is used to sort keys in preparation for selecting the FROID key. + * This sorter defaults to _no sorting whatsoever_. + * + * @param {Key[]} keys - The keys + * @returns {Key[]} the keys in the order provided + */ +const defaultKeySorter: KeySorter = (keys: Key[]): Key[] => keys; + +const defaultNodeQualifier: NodeQualifier = () => true; + +const scalarNames = specifiedScalarTypes.map((scalar) => scalar.name); + +// Custom types that are supported when generating node relay service schema +const typeDefinitionKinds = [ + Kind.SCALAR_TYPE_DEFINITION, + Kind.ENUM_TYPE_DEFINITION, +]; + +/** + * A factory for creating FROID schema AST + */ +export class FroidSchema { + /** + * The contract tags that will be applied to FROID schema. + */ + private readonly contractTags: ConstDirectiveNode[]; + /** + * The Apollo Federation version that will be applied to the FROID schema. + */ + private readonly federationVersion: string; + /** + * The key sorting function. + */ + private readonly keySorter: KeySorter; + /** + * The node qualifier function. + */ + private readonly nodeQualifier: NodeQualifier; + /** + * the list of types that should be omitted from the FROID schema. + */ + private readonly typeExceptions: string[]; + /** + * Definition nodes from across the source schemas after eliminating nodes that should be ignored. + */ + private readonly filteredDefinitionNodes: DefinitionNode[]; + /** + * Object type extension and definition nodes from across all the source schemas. + */ + private readonly extensionAndDefinitionNodes: ObjectTypeNode[]; + /** + * Object type definitions from across the source schemas. + */ + private readonly objectTypes: ObjectTypeDefinitionNode[]; + /** + * The object types that should be included in the FROID schema. + */ + private froidObjectTypes: Record = {}; + /** + * The final FROID AST. + */ + private froidAst: DocumentNode; + + /** + * Creates the FROID schema. + * + * @param {string} name - The name of the subgraph that serves the FROID schema. + * @param {string} federationVersion - The Apollo Federation version to use for the FROID schema. + * @param {Map} schemas - The source schemas from which the FROID schema will be generated. + * @param {FroidSchemaOptions} options - The options for FROID schema generation. + */ + constructor( + name: string, + federationVersion: string, + schemas: Map, + options: FroidSchemaOptions + ) { + this.federationVersion = federationVersion; + + assert( + this.federationVersion.startsWith(FED2_VERSION_PREFIX), + `Federation version must be a valid '${FED2_VERSION_PREFIX}x' version. Examples: v2.0, v2.3` + ); + + this.typeExceptions = options?.typeExceptions ?? []; + this.keySorter = options?.keySorter ?? defaultKeySorter; + this.nodeQualifier = options?.nodeQualifier ?? defaultNodeQualifier; + this.contractTags = + options?.contractTags + ?.sort() + .map((tag) => + FroidSchema.createTagDirective(tag) + ) || []; + + const currentSchemaMap = new Map(schemas); + // Must remove self from map of subgraphs before regenerating schema + currentSchemaMap.delete(name); + + // convert to a flat map of document nodes + const subgraphs = [...currentSchemaMap.values()].map((sdl) => + FroidSchema.parseSchema(sdl) + ); + + // extract all definition nodes for federated schema + const allDefinitionNodes = subgraphs.reduce( + (accumulator, value) => accumulator.concat(value.definitions), + [] + ); + + this.filteredDefinitionNodes = + FroidSchema.removeInterfaceObjects(allDefinitionNodes); + + this.extensionAndDefinitionNodes = this.getNonRootObjectTypes(); + + this.objectTypes = this.getObjectDefinitions(); + + this.findFroidObjectTypes(); + this.generateFroidDependencies(); + + // build schema + this.froidAst = sortDocumentAst({ + kind: Kind.DOCUMENT, + definitions: [ + this.createLinkSchemaExtension(), + this.createQueryDefinition(), + this.createNodeInterface(), + ...this.createCustomReturnTypes(), + ...this.createObjectTypesAST(), + ], + } as DocumentNode); + } + + /** + * Creates the AST for the object types that should be included in the FROID schema. + * + * @returns {ObjectTypeDefinitionNode[]} The generated object types. + */ + private createObjectTypesAST(): ObjectTypeDefinitionNode[] { + return Object.values(this.froidObjectTypes).map((froidObject) => + froidObject.toAst() + ); + } + + /** + * Retrieve the FROID schema AST. + * + * @returns {DocumentNode} The FROID AST. + */ + public toAst(): DocumentNode { + return this.froidAst; + } + + /** + * Retrieve the FROID schema string. + * + * @returns {string} The FROID schema string. + */ + public toString(): string { + return print(this.froidAst); + } + + /** + * Finds the object types that should be included in the FROID schema. + */ + private findFroidObjectTypes() { + this.objectTypes.forEach((node: ObjectTypeDefinitionNode) => { + const isException = this.typeExceptions.some( + (exception) => node.name.value === exception + ); + + const passesNodeQualifier = Boolean( + this.nodeQualifier( + node, + Object.values(this.froidObjectTypes).map((obj) => obj.node) + ) + ); + + if (isException || !passesNodeQualifier || !FroidSchema.isEntity(node)) { + return; + } + + this.createFroidObjectType(node); + }); + } + + /** + * Creates a froid object type. + * + * @param {ObjectTypeDefinitionNode} node - The node the object type will be generated from. + */ + private createFroidObjectType(node: ObjectTypeDefinitionNode) { + const nodeInfo = new ObjectType( + node, + this.froidObjectTypes, + this.objectTypes, + this.extensionAndDefinitionNodes, + this.keySorter, + this.nodeQualifier + ); + + this.froidObjectTypes[node.name.value] = nodeInfo; + } + + /** + * Generates FROID object types for the dependencies of other FROID object types. + */ + private generateFroidDependencies() { + Object.values(this.froidObjectTypes).forEach( + ({selectedKey, allKeyFields}) => { + if (!selectedKey) { + return; + } + this.generateFroidDependency(selectedKey.fields, allKeyFields); + } + ); + } + + /** + * Generates a FROID object type's dependency. + * + * @param {KeyField[]} keyFields - The key fields of a FROID object type + * @param {FieldDefinitionNode[]} fields - The fields of a FROID object type + */ + private generateFroidDependency( + keyFields: KeyField[], + fields: FieldDefinitionNode[] + ) { + keyFields.forEach((keyField) => { + if (!keyField.selections.length) { + return; + } + const currentField = fields.find( + (field) => field.name.value === keyField.name + ); + + if (!currentField) { + return; + } + + const fieldType = FroidSchema.extractFieldType(currentField); + const matchingDefinitionNodes = + this.objectTypes.filter((node) => node.name.value === fieldType) || []; + + if (!matchingDefinitionNodes.length) { + return; + } + + let existingNode = this.froidObjectTypes[fieldType]; + + if (!existingNode) { + this.createFroidObjectType(matchingDefinitionNodes[0]); + existingNode = this.froidObjectTypes[fieldType]; + } + + existingNode.addExternallySelectedFields(keyField.selections); + + this.generateFroidDependency(keyField.selections, existingNode.allFields); + }); + } + + /** + * Returns all non-root types and extended types + * + * @returns {ObjectTypeNode[]} Only ObjectTypeDefinition + ObjectExtensionDefinition nodes that aren't root types + */ + private getNonRootObjectTypes(): ObjectTypeNode[] { + return this.filteredDefinitionNodes.filter( + (node) => + (node.kind === Kind.OBJECT_TYPE_DEFINITION || + node.kind === Kind.OBJECT_TYPE_EXTENSION) && + !FroidSchema.isRootType(node.name.value) + ) as ObjectTypeNode[]; + } + + /** + * Get contract @tag directives for an ID field. Returns all occurrences of unique @tag + * directives used across all fields included in the node's @key directive + * + * @param {ObjectTypeDefinitionNode} node - The node to process `@key` directives for + * @returns {ConstDirectiveNode[]} A list of `@tag` directives to use for the given `id` field + */ + private getTagDirectivesForIdField( + node: ObjectTypeDefinitionNode + ): ConstDirectiveNode[] { + const tagDirectiveNames = this.extensionAndDefinitionNodes + .filter((obj) => obj.name.value === node.name.value) + .flatMap((obj) => { + const taggableNodes = obj.fields?.flatMap((field) => [ + field, + ...(field?.arguments || []), + ]); + return taggableNodes?.flatMap((field) => + field.directives + ?.filter((directive) => directive.name.value === DirectiveName.Tag) + .map( + (directive) => + (directive?.arguments?.[0].value as StringValueNode).value + ) + ); + }) + .filter(Boolean) + .sort() as string[]; + + const uniqueTagDirectivesNames: string[] = [ + ...new Set(tagDirectiveNames || []), + ]; + return uniqueTagDirectivesNames.map((tagName) => + FroidSchema.createTagDirective(tagName) + ); + } + + /** + * Creates the Apollo Federation @link directive. + * + * @param {string[]} imports - The imports to include in the @link directive. + * @returns {SchemaExtensionNode} A schema extension node that includes the @link directive. + */ + private createLinkSchemaExtension( + imports: string[] = DEFAULT_FEDERATION_LINK_IMPORTS + ): SchemaExtensionNode { + if (!imports.length) { + throw new Error('At least one import must be provided.'); + } + + const directiveArguments: readonly ConstArgumentNode[] = [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'url', + }, + value: { + kind: Kind.STRING, + value: FED2_OPT_IN_URL + this.federationVersion, + }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'import', + }, + value: { + kind: Kind.LIST, + values: imports.map((value) => ({ + kind: Kind.STRING, + value: value[0] === '@' ? value : `@${value}`, + })), + }, + }, + ]; + + return { + kind: Kind.SCHEMA_EXTENSION, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: 'link', + }, + arguments: directiveArguments, + }, + ], + }; + } + + /** + * Generates AST for custom scalars and enums used in the froid subgraph. + * + * Enum values with @inaccessible tags are stripped in Federation 2. + * + * Contract @tag directives are NOt applied when generating non-native scalar + * return types in the Froid subgraph. Contract @tag directives are merged + * during supergraph composition so Froid subgraph can rely on @tag directives + * defined by the owning subgraph(s), UNLESS an enum value is marked @inaccessible, + * which is only applicable in Federation 2 schemas. + * + * @returns {DefinitionNode[]} The non-native scalar definitions needed to be definied in the froid subgraph + */ + private createCustomReturnTypes(): SupportedFroidReturnTypes[] { + const froidNodes = Object.values(this.froidObjectTypes); + const froidNodeNames = froidNodes.map((obj) => obj.node.name.value); + + // Extract field return values that aren't native scalars (int, string, boolean, etc.) + // and isn't a type that is already defined in the froid subgraph + const nonNativeScalarDefinitionNames = new Set(); + froidNodes.forEach((obj) => { + obj.selectedFields.forEach((field) => { + const fieldReturnType = FroidSchema.extractFieldType(field); + if ( + !scalarNames.includes(fieldReturnType) && + !froidNodeNames.includes(fieldReturnType) + ) { + nonNativeScalarDefinitionNames.add(fieldReturnType); + } + }); + }); + + // De-dupe non-native scalar return types. Any definitions of scalars and enums + // will work since they can be guaranteed to be consistent across subgraphs + const nonNativeScalarFieldTypes = new Map< + string, + SupportedFroidReturnTypes + >(); + ( + this.filteredDefinitionNodes.filter((definitionNode) => + typeDefinitionKinds.includes(definitionNode.kind) + ) as SupportedFroidReturnTypes[] + ).forEach((nonNativeScalarType) => { + const returnTypeName = nonNativeScalarType.name.value; + if ( + !nonNativeScalarDefinitionNames.has(returnTypeName) || + nonNativeScalarFieldTypes.has(returnTypeName) + ) { + // Don't get types that are not returned in froid schema + return; + } + + if (nonNativeScalarType.kind === Kind.ENUM_TYPE_DEFINITION) { + const enumValues = nonNativeScalarType.values?.map((enumValue) => ({ + ...enumValue, + directives: enumValue.directives?.filter( + (directive) => directive.name.value === 'inaccessible' + ), + })); + nonNativeScalarFieldTypes.set(returnTypeName, { + ...nonNativeScalarType, + values: enumValues, + directives: [], + description: undefined, + } as EnumTypeDefinitionNode); + return; + } + + nonNativeScalarFieldTypes.set(returnTypeName, { + ...nonNativeScalarType, + description: undefined, + directives: [], + } as ScalarTypeDefinitionNode); + }); + + return [...nonNativeScalarFieldTypes.values()]; + } + + /** + * Generates AST for the following type: + * type Query { + * node(id: ID!): RelayNodeEntity + * } + * + * @returns {ObjectTypeDefinitionNode} The Query definition for the Relay Object Identification schema + */ + private createQueryDefinition(): ObjectTypeDefinitionNode { + return { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { + kind: Kind.NAME, + value: 'Query', + }, + interfaces: [], + directives: [], + fields: [ + { + kind: Kind.FIELD_DEFINITION, + description: { + kind: Kind.STRING, + value: 'Fetches an entity by its globally unique identifier.', + }, + name: { + kind: Kind.NAME, + value: 'node', + }, + arguments: [ + { + kind: Kind.INPUT_VALUE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'A globally unique entity identifier.', + }, + name: { + kind: Kind.NAME, + value: ID_FIELD_NAME, + }, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: ID_FIELD_TYPE, + }, + }, + }, + directives: [], + }, + ], + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Node', + }, + }, + directives: this.contractTags, + }, + ], + }; + } + + /** + * Represents AST for Node type + * interface Node { + * id: ID! + * } + * + * @returns {InterfaceTypeDefinitionNode} The Node interface definition for the Relay Object Identification schema + */ + private createNodeInterface(): InterfaceTypeDefinitionNode { + return { + kind: Kind.INTERFACE_TYPE_DEFINITION, + description: { + kind: Kind.STRING, + value: + 'The global identification interface implemented by all entities.', + }, + name: { + kind: Kind.NAME, + value: 'Node', + }, + fields: [FroidSchema.createIdField()], + directives: this.contractTags, + }; + } + + /** + * Generates an @tag directive node + * + * @param {string} name - The name of the tag + * @returns {ConstDirectiveNode} A directive AST node for @tag + */ + public static createTagDirective(name: string): ConstDirectiveNode { + return { + kind: Kind.DIRECTIVE, + name: {kind: Kind.NAME, value: CONTRACT_DIRECTIVE_NAME}, + arguments: [ + { + kind: Kind.ARGUMENT, + name: {kind: Kind.NAME, value: 'name'}, + value: { + kind: Kind.STRING, + value: name, + }, + }, + ], + }; + } + + /** + * Extract type from a field definition node + * + * @param {any} node - The node we want to extract a field type from + * @returns {string} The name of the type used to define a field + */ + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + public static extractFieldType(node: any): string { + if (node.hasOwnProperty('type')) { + return FroidSchema.extractFieldType(node.type); + } + return node?.name?.value; + } + + /** + * Represents AST for the `id` field + * ... + * id: ID! + * ... + * + * @param {ConstDirectiveNode[]} directives - The directives to add to the field definition + * @returns {FieldDefinitionNode} The `id` field definition + */ + public static createIdField( + directives: ConstDirectiveNode[] = [] + ): FieldDefinitionNode { + return { + kind: Kind.FIELD_DEFINITION, + description: { + kind: Kind.STRING, + value: `The globally unique identifier.`, + }, + name: { + kind: Kind.NAME, + value: ID_FIELD_NAME, + }, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: ID_FIELD_TYPE, + }, + }, + }, + directives, + }; + } + + /** + * @param {string} nodeNameValue - The node name + * @returns {boolean} Whether or not the node is a root type + */ + private static isRootType(nodeNameValue: string): boolean { + return ['Query', 'Mutation', 'Subscription'].includes(nodeNameValue); + } + + /** + * Returns all non-extended types with explicit ownership to a single subgraph + * + * @returns {ObjectTypeNode[]} Only ObjectTypeDefinition + ObjectExtensionDefinition nodes that aren't root types + */ + private getObjectDefinitions(): ObjectTypeDefinitionNode[] { + return this.extensionAndDefinitionNodes.filter( + (node) => + // only type definitions + node.kind === Kind.OBJECT_TYPE_DEFINITION && + // No entities with `id` fields + !node.fields?.some((field) => field.name.value === ID_FIELD_NAME) && + !node.directives?.some( + (directive) => + // exclude @extends directive + directive.name.value === DirectiveName.Extends || + // exclude entity references, i.e. @key(fields: "...", resolvable: false) + directive.arguments?.some( + (argument) => + argument.name.value === 'resolvable' && + argument.value.kind === Kind.BOOLEAN && + !argument.value.value + ) + ) + ) as ObjectTypeDefinitionNode[]; + } + + /** + * Checks if given node is an ObjectTypeDefinition without an @extends directive. + * + * @param {DefinitionNode} node - Node to check + * @returns {boolean} True if node is an ObjectTypeDefinition without an @extends directive, false otherwise. + */ + private static isObjectTypeExtension(node: DefinitionNode): boolean { + return !!( + node.kind === Kind.OBJECT_TYPE_EXTENSION || + (node.kind === Kind.OBJECT_TYPE_DEFINITION && + node.directives?.some( + (directive) => directive.name.value === DirectiveName.Extends + )) + ); + } + + /** + * Removes nodes from the list that have @interfaceObject directive or their name is in extensionsWithInterfaceObject. + * + * @param {DefinitionNode[]} nodes - Array of nodes to filter + * @param {string[]} extensionsWithInterfaceObject - Array of names to exclude + * @returns {DefinitionNode[]} DefinitionNode[] - Filtered array of nodes. + */ + private static removeInterfaceObjectsFromNodes( + nodes: DefinitionNode[], + extensionsWithInterfaceObject: string[] + ): DefinitionNode[] { + return nodes.filter( + (node) => + !( + ('directives' in node && + node.directives?.some( + (directive) => + directive.name.value === DirectiveName.InterfaceObject + )) || + ('name' in node && + node.name && + extensionsWithInterfaceObject.includes(node.name.value)) + ) + ); + } + + /** + * Checks if given node is an ObjectTypeExtension and has @interfaceObject directive. + * Checks for both `extend type Foo` and Java-style `type Foo @extends` syntax + * + * @param {DefinitionNode} node - Node to check + * @returns {boolean} True if node is ObjectTypeExtension and has @interfaceObject directive, false otherwise. + */ + private static getObjectTypeExtensionsWithInterfaceObject( + node: DefinitionNode + ): boolean { + return !!( + FroidSchema.isObjectTypeExtension(node) && + 'directives' in node && + node.directives?.some( + (directive) => directive.name.value === 'interfaceObject' + ) + ); + } + + /** + * Removes all ObjectTypeDefinition and ObjectTypeExtension nodes with @interfaceObject + * directive. + * + * This is done because otherwise there is a type conflict in composition between + * node-relay subgraph and subgraphs implementing the types with @interfaceObject + * + * Concrete implementers of the interface are entities themselves, so corresponding + * node-relay subgraph types will still be generated for those. + * + * See https://www.apollographql.com/docs/federation/federated-types/interfaces/ + * for more info on the use of @interfaceObject (requires Federation Spec v2.3 or + * higher) + * + * @param {DefinitionNode[]} nodes - Schema AST nodes + * @returns {DefinitionNode[]} Only nodes that are not using @interfaceObject + */ + private static removeInterfaceObjects( + nodes: DefinitionNode[] + ): DefinitionNode[] { + const objectTypeExtensionsWithInterfaceObject = nodes + .filter(FroidSchema.getObjectTypeExtensionsWithInterfaceObject) + .flatMap((node) => + 'name' in node && node.name?.value ? node.name.value : [] + ); + + return FroidSchema.removeInterfaceObjectsFromNodes( + nodes, + objectTypeExtensionsWithInterfaceObject + ); + } + + /** + * Check whether or not a list of nodes contains an entity. + * + * @param {ObjectTypeNode[]} nodes - The nodes to check + * @returns {boolean} Whether or not any nodes are entities + */ + public static isEntity(nodes: ObjectTypeNode[]): boolean; + /** + * Check whether or not a node is an entity. + * + * @param {ObjectTypeNode} node - A node to check + * @returns {boolean} Whether or not the node is an entity + */ + public static isEntity(node: ObjectTypeNode): boolean; + /** + * Check whether or not one of more nodes is an entity. + * + * @param {ObjectTypeNode | ObjectTypeNode[]} node - One or more nodes to collectively check + * @returns {boolean} Whether or not any nodes are entities + */ + public static isEntity(node: ObjectTypeNode | ObjectTypeNode[]): boolean { + const nodesToCheck = Array.isArray(node) ? node : [node]; + return nodesToCheck.some((node) => + node?.directives?.some( + (directive) => directive.name.value === DirectiveName.Key + ) + ); + } + + /** + * Parse a schema string to AST. + * + * @param {string} schema - The schema string. + * @returns {DocumentNode} AST + */ + private static parseSchema(schema: string): DocumentNode { + return parse(schema, {noLocation: true}); + } +} diff --git a/src/schema/Key.ts b/src/schema/Key.ts new file mode 100644 index 0000000..86ad299 --- /dev/null +++ b/src/schema/Key.ts @@ -0,0 +1,316 @@ +import { + ConstDirectiveNode, + DocumentNode, + FieldNode, + Kind, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, + StringValueNode, + parse, + print, +} from 'graphql'; +import {KeyField} from './KeyField'; +import {ObjectTypeNode} from './types'; +import assert from 'assert'; +import { + DirectiveName, + KeyDirectiveArgument, + TYPENAME_FIELD_NAME, +} from './constants'; + +const WRAPPING_CURLY_BRACES_REGEXP = /^\{(.*)\}$/s; +const INDENTED_MULTILINE_REGEXP = /(\n|\s)+/g; + +/** + * Represents an object key directive as a structures object. + */ +export class Key { + /** + * The key's fields. + */ + private _fields: KeyField[] = []; + + /** + * Creates a key from the key fields of an object type. + * + * @param {string} typename - The name of the object type the key is associated with + * @param {string} keyFields - The fields string of the object's key directive + */ + constructor(typename: string, keyFields: string); + /** + * Creates a key from the key directive AST of an object type. + * + * @param {string} typename - The name of the object type the key is associated with + * @param {ConstDirectiveNode} keyDirective - The object's key directive. + */ + constructor(typename: string, keyDirective: ConstDirectiveNode); + /** + * Creates a key from an object type, key directive AST, or key directive fields string. + * + * @param {string|ObjectTypeNode} typename - An object type or its name + * @param {string|ConstDirectiveNode} keyFields - The object's key directive or fields + */ + constructor( + public readonly typename: string, + keyFields: string | ConstDirectiveNode + ) { + this.parseToFields(keyFields); + return; + } + + /** + * Parses a field string/directive AST to key fields. + * + * @param {string | ConstDirectiveNode} fieldsOrDirective - The fields string/directive AST to parse + * @returns {void} + */ + private parseToFields(fieldsOrDirective: string | ConstDirectiveNode): void { + let parseableField = fieldsOrDirective; + if (typeof parseableField !== 'string') { + parseableField = Key.getKeyDirectiveFields(parseableField); + } + ( + Key.parseKeyFields(parseableField) + .definitions[0] as OperationDefinitionNode + )?.selectionSet?.selections?.forEach((selection) => + this.addSelection(selection) + ); + } + + /** + * Adds a key field selection to the key's fields. + * + * @param {SelectionNode} selection - A key field selection from AST + * @returns {void} + */ + public addSelection(selection: SelectionNode): void { + assert( + selection.kind === Kind.FIELD, + `Encountered @key "fields" selection of kind "${selection.kind}" on type "${this.typename}". @key selections must be fields.` + ); + if ( + selection.name.value === TYPENAME_FIELD_NAME || + this._fields.find((field) => field.name === selection.name.value) + ) { + return; + } + this._fields.push(new KeyField(selection)); + } + + /** + * Merges another key with this key. + * + * @param {Key} key - The key that will be merged into this key. + * @returns {void} + */ + public merge(key: Key): void { + key._fields.forEach((mergeField) => { + const existingField = this._fields.find( + (compareField) => compareField.name === mergeField.name + ); + if (!existingField) { + this._fields.push(mergeField); + return; + } + existingField.merge(mergeField); + }); + } + + /** + * The names of the first level of fields in a key. + * + * @returns {string[]} The key field names + */ + public get fieldsList(): string[] { + return this._fields.map((field) => field.name); + } + + /** + * The list of fields in the key. + * + * @returns {KeyField[]} The list of key fields. + */ + public get fields(): KeyField[] { + return this._fields; + } + + /** + * How many object levels deep the key fields go. + * + * Examples: + * 'foo' => Depth 0 + * 'bar { barId }' => Depth 1 + * 'bar { barId } baz { bazId }' => Depth 1 + * 'baz { bazId qux { quxId } }' => Depth 2 + * 'bar { barId } baz { bazId qux { quxId } }' => Depth 2 + * + * @returns {number} The key fields depth. + */ + public get depth(): number { + return this.calculateDepth(this._fields); + } + + /** + * Recursively calculates the key depth. + * + * @param {KeyField[]} keyFields - The key fields at the current depth level. Defaults to zero (0). + * @param {number} currentDepth - The current depth level + * @returns {number} The depth level as calculated + */ + private calculateDepth(keyFields: KeyField[], currentDepth = 0): number { + const allSelections = keyFields.flatMap((keyField) => keyField.selections); + if (allSelections.length) { + currentDepth += 1; + currentDepth = this.calculateDepth(allSelections, currentDepth); + } + return currentDepth; + } + + /** + * Converts the key to a fields string for use in a schema key directive. + * + * @returns {string} The fields string + */ + public toString(): string { + return Key.getSortedSelectionSetFields( + this._fields.map((field) => field.toString()).join(' ') + ); + } + + /** + * Converts the key to schema directive AST. + * + * @returns {ConstDirectiveNode} The schema directive AST + */ + public toDirective(): ConstDirectiveNode { + return { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: DirectiveName.Key, + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: KeyDirectiveArgument.Fields, + }, + value: { + kind: Kind.STRING, + value: this.toString(), + }, + }, + ], + }; + } + + /** + * Parses a key fields string into AST. + * + * @param {string} keyFields - The key fields string + * @returns {DocumentNode} The key fields represented in AST + */ + private static parseKeyFields(keyFields: string): DocumentNode { + return parse(`{${keyFields}}`, {noLocation: true}); + } + + /** + * Gets the fields string from a key directive's AST + * + * @param {ConstDirectiveNode} key - The key directive AST + * @returns {string} The key directive's fields + */ + private static getKeyDirectiveFields(key: ConstDirectiveNode): string { + return ( + key.arguments?.find( + (arg) => arg.name.value === KeyDirectiveArgument.Fields + )?.value as StringValueNode + ).value; + } + + /** + * Sorts the selection set fields. + * + * @param {string} fields - The selection set fields. + * @returns {string} The sorted selection set fields. + */ + public static getSortedSelectionSetFields(fields: string): string { + const selections = Key.sortSelectionSetByNameAscending( + (Key.parseKeyFields(fields).definitions[0] as OperationDefinitionNode) + .selectionSet + ); + return Key.formatSelectionSetFields(print(selections)); + } + + /** + * Sorts the selection set by name, ascending. + * + * @param {SelectionSetNode | SelectionNode} node - The selection set node. + * @returns {SelectionSetNode | SelectionNode} The sorted selection set. + */ + protected static sortSelectionSetByNameAscending< + T extends SelectionSetNode | SelectionNode + >( + node: T + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): T { + if (node.kind === Kind.SELECTION_SET) { + const selections = node.selections + .map((selection) => { + return this.sortSelectionSetByNameAscending(selection) as FieldNode; + }) + .sort(Key.sortASTByNameAscending); + return { + ...node, + selections, + } as T; + } + if (node.kind === Kind.FIELD) { + if (node.selectionSet) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const selections = (node.selectionSet.selections as any) + .map((selection) => { + return Key.sortSelectionSetByNameAscending(selection); + }) + .sort(Key.sortASTByNameAscending); + return { + ...node, + selectionSet: { + ...node.selectionSet, + selections, + }, + }; + } + return node; + } + return node; + } + + /** + * Sorts AST by name, ascending. + * + * @param {FieldNode} a - The first node to be compared + * @param {FieldNode} b - The second node to be compared + * @returns {number} The ordinal adjustment to be made + */ + protected static sortASTByNameAscending(a: FieldNode, b: FieldNode): number { + return a.name.value.localeCompare(b.name.value); + } + + /** + * Formats a selection set string for use in a directive. + * + * @param {string} selectionSetString - The selection set string. + * @returns {string} The formatted selection set string. + */ + protected static formatSelectionSetFields( + selectionSetString: string + ): string { + return selectionSetString + .replace(WRAPPING_CURLY_BRACES_REGEXP, '$1') + .replace(INDENTED_MULTILINE_REGEXP, ' ') + .trim(); + } +} diff --git a/src/schema/KeyField.ts b/src/schema/KeyField.ts new file mode 100644 index 0000000..e57b16e --- /dev/null +++ b/src/schema/KeyField.ts @@ -0,0 +1,99 @@ +import {FieldNode} from 'graphql'; +import {TYPENAME_FIELD_NAME} from './constants'; + +/** + * Represents an object key's field and its child selections as a structured object. + */ +export class KeyField { + /** + * The name of the field. + */ + public readonly name: string; + /** + * The child selections of the field. + */ + private _selections: KeyField[] = []; + + /** + * Creates a key field. + * + * @param {FieldNode} field - The field this key field will represent + */ + constructor(field: FieldNode) { + this.name = field.name.value; + field.selectionSet?.selections?.forEach((selection) => + // Key selections can only contain Object Type fields + this.addSelection(selection as FieldNode) + ); + } + + /** + * Add a selection to the key field's child selections. + * + * @param {FieldNode} selection - Selection field AST + * @returns {void} + */ + public addSelection(selection: FieldNode): void { + if ( + selection.name.value === TYPENAME_FIELD_NAME || + this._selections.find((field) => field.name === selection.name.value) + ) { + return; + } + this._selections.push(new KeyField(selection)); + } + + /** + * Merges another key field into this key field. + * + * @param {KeyField} keyField - The key field that will be merged with this key field. + * @returns {void} + */ + public merge(keyField: KeyField): void { + keyField._selections.forEach((mergeSelection) => { + const existingSelection = this._selections.find( + (compareSelection) => compareSelection.name === mergeSelection.name + ); + if (!existingSelection) { + this._selections.push(mergeSelection); + return; + } + existingSelection.merge(mergeSelection); + }); + } + + /** + * The key field's child selections. + * + * @returns {KeyField[]} The child selections + */ + public get selections(): KeyField[] { + return this._selections; + } + + /** + * Converts this key field and its child selections to a string. + * + * @returns {string} The key field as a string + */ + public toString(): string { + return [this.name, ...this.selectionsToString()].join(' '); + } + + /** + * The list of this key field's child selections, converted to strings. + * + * @returns {string[]} The list of child selections as strings + */ + private selectionsToString(): string[] { + if (!this._selections.length) { + return []; + } + return [ + '{', + TYPENAME_FIELD_NAME, + ...this._selections.map((selection) => selection.toString()), + '}', + ]; + } +} diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts new file mode 100644 index 0000000..5cd4044 --- /dev/null +++ b/src/schema/ObjectType.ts @@ -0,0 +1,564 @@ +import { + ConstDirectiveNode, + FieldDefinitionNode, + NamedTypeNode, + ObjectTypeDefinitionNode, + StringValueNode, +} from 'graphql'; +import {Key} from './Key'; +import {DirectiveName, EXTERNAL_DIRECTIVE_AST} from './constants'; +import {ObjectTypeNode} from './types'; +import {FroidSchema, KeySorter, NodeQualifier} from './FroidSchema'; +import {KeyField} from './KeyField'; +import {implementsNodeInterface} from './astDefinitions'; + +const FINAL_KEY_MAX_DEPTH = 100; + +/** + * Collates information about an object type definition node. + */ +export class ObjectType { + /** + * The name of the object type. + */ + public readonly typename: string; + /** + * All occurrences of the node across all subgraph schemas. + */ + public readonly occurrences: ObjectTypeNode[]; + /** + * All keys applied to all occurrences of the node. + */ + public readonly keys: Key[]; + /** + * All the child fields from all occurrences of the node as records. + */ + public readonly allFieldRecords: Record; + /** + * All the child fields from all occurrences of the node as a list. + */ + public readonly allFields: FieldDefinitionNode[]; + /** + * The names of all the fields that appear in the keys of the node. + */ + public readonly allKeyFieldsList: string[]; + /** + * All the fields that appear in the keys of the node. + */ + public readonly allKeyFields: FieldDefinitionNode[]; + /** + * Whether or not the object is an entity. + */ + public readonly isEntity: boolean; + /** + * Fields belonging to this object type that were selected for + * use in the keys of other object types. + */ + private _externallySelectedFields: string[] = []; + /** + * The key selected for use in the FROID schema. + */ + private _selectedKey: Key | undefined; + /** + * The list of child objects that appear in the selected key. + * Each record is made up of the field referencing a child object + * and the object it is referencing. + * + * Example schema: + * type Book @key(fields: "theBookAuthor { name }") { + * theBookAuthor: Author! + * } + * type Author { + * name + * } + * + * Example record: + * { "theBookAuthor": "Author" } + */ + private childObjectsInSelectedKey: Record; + /** + * The names of the fields that are being used by the node itself. + * + * Example schema: + * type Book @key(fields: "author { name }") { + * author: Author! + * } + * type Author @key(fields: "authorId") { + * authorId: Int! + * name: String! + * } + * + * Example value: + * ['authorId'] + */ + private directlySelectedFields: string[]; + + /** + * + * @param {ObjectTypeDefinitionNode} node - The node for which information is being collated + * @param {Record} froidObjectTypes - Information about the object types collected to be included in the FROID schema + * @param {ObjectTypeDefinitionNode[]} objectTypes - The object type definitions from across the source schemas + * @param {ObjectTypeNode[]} extensionAndDefinitionNodes - The object type definition and extension nodes from across the source schemas + * @param {KeySorter} keySorter - A function for sorting object keys prior to selection + * @param {NodeQualifier} nodeQualifier - A function for qualifying whether a node should be used for populating the FROID schema or not + */ + constructor( + public readonly node: ObjectTypeDefinitionNode, + private readonly froidObjectTypes: Record, + private readonly objectTypes: ObjectTypeDefinitionNode[], + private readonly extensionAndDefinitionNodes: ObjectTypeNode[], + private readonly keySorter: KeySorter, + private readonly nodeQualifier: NodeQualifier + ) { + this.typename = this.node.name.value; + this.occurrences = this.getOccurrences(); + this.keys = this.getKeys(); + this.allFieldRecords = this.getAllFieldRecords(); + this.allFields = this.getAllFields(); + this.allKeyFieldsList = this.getAllKeyFieldsList(); + this.allKeyFields = this.getAllKeyFields(); + this._selectedKey = this.getSelectedKey(); + this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); + this.directlySelectedFields = this.getDirectlySelectedFields(); + this.isEntity = FroidSchema.isEntity(this.occurrences); + } + + /** + * Get all occurrences of the node across all subgraph schemas. + * + * @returns {ObjectTypeNode[]} The list of occurrences + */ + private getOccurrences(): ObjectTypeNode[] { + return this.extensionAndDefinitionNodes.filter( + (searchNode) => searchNode.name.value === this.typename + ); + } + + /** + * Get all keys applied to all occurrences of the node. + * + * @returns {Key[]} The list of keys + */ + private getKeys(): Key[] { + return this.occurrences.flatMap( + (occurrence) => + occurrence.directives + ?.filter((directive) => directive.name.value === DirectiveName.Key) + .map((key) => new Key(this.typename, key)) || [] + ); + } + + /** + * Get all the child fields from all occurrences of the node as records. + * + * @returns {Record} The of field records + */ + private getAllFieldRecords(): Record { + const fields: Record = {}; + this.occurrences.forEach((occurrence) => + occurrence?.fields?.forEach((field) => { + if (!fields[field.name.value]) { + fields[field.name.value] = null; + } + this.addQualifiedField(field, fields); + }) + ); + Object.entries(fields) + .filter(([, field]) => field === null) + .forEach(([fieldName]) => { + this.occurrences.some((occurrence) => { + occurrence?.fields?.forEach((field) => { + if (field.name.value !== fieldName) { + return false; + } + this.addQualifiedField(field, fields, false); + return true; + }); + }); + }); + return Object.fromEntries( + Object.entries(fields).filter(([, def]) => Boolean(def)) as [ + string, + FieldDefinitionNode + ][] + ); + } + + /** + * Get all the child fields from all occurrences of the node. + * + * @returns {FieldDefinitionNode[]} The list of fields + */ + private getAllFields(): FieldDefinitionNode[] { + return Object.values(this.allFieldRecords); + } + + /** + * Add a qualified field to the list of fields. + * + * @param {FieldDefinitionNode} field - The field to add. + * @param {FieldDefinitionNode[]} fields - The current list of collected fields. + * @param {boolean} applyNodeQualifier - Whether or not to usethe nodeQualifier when selecting the node. Defaults to 'true'. + */ + private addQualifiedField( + field: FieldDefinitionNode, + fields: Record, + applyNodeQualifier = true + ): void { + if ( + fields[field.name.value] !== null || + (applyNodeQualifier && !this.nodeQualifier(field, this.objectTypes)) + ) { + // If the field is already in the list + // of if the field must pass the node qualifier and fails to + // don't add it again + return; + } + // Add the node + fields[field.name.value] = field; + } + + /** + * Get the names of all the fields that appear in the keys of the node. + * + * @returns {string[]} The list of key field names + */ + private getAllKeyFieldsList(): string[] { + return [...new Set(this.keys.flatMap((key) => key.fieldsList))]; + } + + /** + * Get all the fields that appear in the keys of the node. + * + * @returns {FieldDefinitionNode[]} The list of key fields + */ + public getAllKeyFields(): FieldDefinitionNode[] { + return this.allFields.filter((field) => + this.allKeyFieldsList.includes(field.name.value) + ); + } + + /** + * Get the key selected for use in the FROID schema. + * + * @returns {Key|undefined} The selected key + */ + private getSelectedKey(): Key | undefined { + return this.keySorter(this.keys, this.node)[0]; + } + + /** + * The list of child objects that appear in the selected key. + * Each record is made up of the field referencing a child object + * and the object it is referencing. + * + * @returns {Record} The list of fields that reference a child object and the object the field is referencing + */ + public getChildObjectsInSelectedKey(): Record { + const children: Record = {}; + if (!this._selectedKey) { + return children; + } + this._selectedKey.fieldsList.forEach((keyField) => { + const field = this.allFieldRecords[keyField]; + const fieldType = FroidSchema.extractFieldType(field); + if ( + !this.objectTypes.find( + (searchType) => searchType.name.value === fieldType + ) + ) { + return; + } + children[field.name.value] = fieldType; + }); + return children; + } + + /** + * Get the names of the fields that are being used by the node itself. + * + * @returns {string[]} The list of field names + */ + public getDirectlySelectedFields(): string[] { + return ( + this._selectedKey?.fieldsList?.filter((keyField) => + Boolean(this.allFieldRecords[keyField]) + ) || [] + ); + } + + /** + * The names of the fields that are referenced in another entity's key. + * + * Example schema: + * type Book @key(fields: "author { name }") { + * author: Author! + * } + * type Author { + * name: String! + * } + * + * Example value: + * ['name'] + * + * @returns {string[]} The list of field names + */ + public get externallySelectedFields(): string[] { + return this._externallySelectedFields; + } + + /** + * The list of all fields referenced in the node key and by other entities. + * + * @returns {FieldDefinitionNode} The list of fields + */ + public get selectedFields(): FieldDefinitionNode[] { + return [ + ...new Set([ + ...this.directlySelectedFields, + ...this.externallySelectedFields, + ]), + ] + .map((keyField) => { + const field = this.allFieldRecords[keyField]; + if (field) { + return field; + } + }) + .filter(Boolean) as FieldDefinitionNode[]; + } + + /** + * The key selected for use in the FROID schema. + * + * @returns {Key | undefined} The selected key + */ + public get selectedKey(): Key | undefined { + return this._selectedKey; + } + + /** + * The node's key after all key fields used by other entities are added. + * + * @returns {Key|undefined} The final key. Undefined if the node is not an entity. + */ + public get finalKey(): Key | undefined { + return this.getFinalKey(); + } + + /** + * Generates the final key for the node based on all descendant types and their keys (if they have keys). + * + * @param {number} depth - The current nesting depth of the key. Defaults to 0. + * @param {string[]} ancestors - The type name of ancestors that have been traversed up to the current key depth. + * @returns {Key|undefined} The final key or undefined if the node has no key. + */ + private getFinalKey(depth = 0, ancestors: string[] = []): Key | undefined { + if (!this._selectedKey) { + return; + } + + if (depth > FINAL_KEY_MAX_DEPTH) { + console.error( + `Encountered max entity key depth on type '${ + this.typename + }'. Depth: ${depth}; Ancestors: "${ancestors.join('", "')}"` + ); + return; + } + + const usedKeys = this.getUsedKeys(); + const updatedSelectedKey = usedKeys.shift(); + + if (!updatedSelectedKey) { + return; + } + + // Update the selected key in case a different key was selected + this.setSelectedKey(updatedSelectedKey); + + const mergedKey = new Key(this.typename, this._selectedKey.toString()); + const selectedKeyFields = [ + ...usedKeys.flatMap((key) => key.toString()), + ].join(' '); + + if (selectedKeyFields) { + const keyFromSelections = new Key(this.typename, selectedKeyFields); + mergedKey.merge(keyFromSelections); + } + Object.entries(this.childObjectsInSelectedKey).forEach( + ([dependentField, dependencyType]) => { + if (ancestors.includes(dependencyType)) { + console.error( + `Encountered node FROID final key recursion on type "${dependencyType}". Ancestors: "${ancestors.join( + '", "' + )}"` + ); + return; + } + const dependency = this.froidObjectTypes[dependencyType]; + const dependencyFinalKey = dependency.getFinalKey(depth + 1, [ + ...ancestors, + this.typename, + ]); + if (!dependencyFinalKey) { + return; + } + const keyToMerge = new Key( + this.typename, + `${dependentField} { ${dependencyFinalKey.toString()} }` + ); + mergedKey.merge(keyToMerge); + } + ); + return mergedKey; + } + + /** + * Assigns a new selected key. + * + * @param {Key} key - The key + */ + private setSelectedKey(key: Key) { + this._selectedKey = key; + this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); + this.directlySelectedFields = this.getDirectlySelectedFields(); + } + + /** + * Gets the keys used either by default or in other entity keys. + * + * @returns {Key[]} The used keys + */ + private getUsedKeys(): Key[] { + if (!this.isEntity || !this._selectedKey) { + return []; + } + + const usedExternalKeys = this.keys.filter((key) => + key.fieldsList.some((field) => + this._externallySelectedFields.includes(field) + ) + ); + if (!usedExternalKeys.length) { + return [this._selectedKey]; + } + return usedExternalKeys.sort((aKey, bKey) => { + if (aKey === this._selectedKey) { + return -1; + } + if (bKey === this._selectedKey) { + return 1; + } + return 0; + }); + } + + /** + * Gets the final fields for the object. + * + * @param {key|undefined} finalKey - The final key of the object + * @returns {FieldDefinitionNode[]} The final fields + */ + private getFinalFields(finalKey: Key | undefined): FieldDefinitionNode[] { + const fieldsList = [ + ...new Set([ + ...(finalKey?.fieldsList || []), + ...this._externallySelectedFields, + ]), + ]; + const fields = fieldsList + .map((fieldName) => { + const fieldDef = this.allFieldRecords[fieldName]; + if (!fieldDef) { + return; + } + const isExternalEntityField = + !this.isEntity || this.allKeyFieldsList.includes(fieldName); + return { + ...fieldDef, + description: undefined, + directives: isExternalEntityField + ? undefined + : [EXTERNAL_DIRECTIVE_AST], + }; + }) + .filter(Boolean) as FieldDefinitionNode[]; + return fields; + } + + /** + * Get contract @tag directives for an ID field. Returns all occurrences of unique @tag + * directives used across all fields included in the node's @key directive + * + * @returns {ConstDirectiveNode[]} A list of `@tag` directives to use for the given `id` field + */ + private getTagDirectivesForIdField(): ConstDirectiveNode[] { + const tagDirectiveNames = this.extensionAndDefinitionNodes + .filter((obj) => obj.name.value === this.typename) + .flatMap((obj) => { + const taggableNodes = obj.fields?.flatMap((field) => [ + field, + ...(field?.arguments || []), + ]); + return taggableNodes?.flatMap((field) => + field.directives + ?.filter((directive) => directive.name.value === DirectiveName.Tag) + .map( + (directive) => + (directive?.arguments?.[0].value as StringValueNode).value + ) + ); + }) + .filter(Boolean) + .sort() as string[]; + + const uniqueTagDirectivesNames: string[] = [ + ...new Set(tagDirectiveNames || []), + ]; + return uniqueTagDirectivesNames.map((tagName) => + FroidSchema.createTagDirective(tagName) + ); + } + + /** + * Adds the names of fields used by other entities to the list of externally selected fields. + * + * @param {KeyField[]} fields - The key fields to add to the list + * @returns {void} + */ + public addExternallySelectedFields(fields: KeyField[]): void { + this._externallySelectedFields = [ + ...new Set([ + ...this._externallySelectedFields, + ...fields.map((field) => field.name), + ]), + ]; + } + + /** + * Creates the object's AST. + * + * @returns {ObjectTypeDefinitionNode} The object's AST. + */ + public toAst(): ObjectTypeDefinitionNode { + let froidFields: FieldDefinitionNode[] = []; + let froidInterfaces: NamedTypeNode[] = []; + if (this.isEntity) { + froidFields = [ + FroidSchema.createIdField(this.getTagDirectivesForIdField()), + ]; + froidInterfaces = [implementsNodeInterface]; + } + const finalKey = this.finalKey; + const finalKeyDirective = finalKey?.toDirective(); + const fields = [...froidFields, ...this.getFinalFields(finalKey)]; + return { + ...this.node, + description: undefined, + interfaces: froidInterfaces, + directives: [...(finalKeyDirective ? [finalKeyDirective] : [])], + fields, + }; + } +} diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts new file mode 100644 index 0000000..57c5c05 --- /dev/null +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -0,0 +1,2582 @@ +import {stripIndent as gql} from 'common-tags'; +import {FroidSchema, KeySorter, NodeQualifier} from '../FroidSchema'; +import {Kind} from 'graphql'; +import {FED2_DEFAULT_VERSION} from '../constants'; + +function generateSchema({ + subgraphs, + froidSubgraphName, + contractTags = [], + typeExceptions = [], + federationVersion, + nodeQualifier, + keySorter, +}: { + subgraphs: Map; + froidSubgraphName: string; + contractTags?: string[]; + typeExceptions?: string[]; + federationVersion: string; + nodeQualifier?: NodeQualifier; + keySorter?: KeySorter; +}) { + const froidSchema = new FroidSchema( + froidSubgraphName, + federationVersion, + subgraphs, + { + contractTags, + typeExceptions, + nodeQualifier, + keySorter, + } + ); + + return froidSchema.toString(); +} + +describe('FroidSchema class', () => { + it('requires a federation version', () => { + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + let errorMessage = ''; + try { + generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: 'v3.1', + }); + } catch (err) { + errorMessage = err.message; + } + + expect(errorMessage).toMatch( + `Federation version must be a valid 'v2.x' version` + ); + }); + + it('uses the first entity key found regardless of complexity by default', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc sku brand { brandId store { storeId } }") + @key(fields: "upc sku") + @key(fields: "upc") + @key(fields: "sku brand { brandId store { storeId } }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Brand { + brandId: Int! + store: Store + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "brand { __typename brandId store { __typename storeId } } sku upc") { + "The globally unique identifier." + id: ID! + brand: [Brand!]! + sku: String! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Store { + storeId: Int! + } + ` + ); + }); + + it('includes entities from multiple subgraph schemas', () => { + const productSchema = gql` + type Query { + user(id: String): User + } + + type User @key(fields: "userId") { + userId: String! + name: String! + } + `; + + const todoSchema = gql` + type User @key(fields: "userId") { + userId: String! + todos( + status: String = "any" + after: String + first: Int + before: String + last: Int + ): TodoConnection + totalCount: Int! + completedCount: Int! + } + + type TodoConnection { + pageInfo: PageInfo! + edges: [TodoEdge] + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type TodoEdge { + node: Todo + cursor: String! + } + + type Todo @key(fields: "todoId") { + todoId: Int! + text: String! + complete: Boolean! + } + + type Mutation { + addTodo(input: AddTodoInput!): AddTodoPayload + } + + input AddTodoInput { + text: String! + userId: ID! + clientMutationId: String + } + + type AddTodoPayload { + todoEdge: TodoEdge! + user: User! + clientMutationId: String + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + subgraphs.set('todo-subgraph', todoSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: Int! + } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } + ` + ); + }); + + it('includes custom scalar definitions when they are used as the return type for a key field', () => { + const userSchema = gql` + scalar UsedCustomScalar1 + scalar UsedCustomScalar2 + scalar UnusedCustomScalar + + type Query { + user(id: String): User + } + + type User @key(fields: "userId customField1 customField2") { + userId: String! + name: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + unusedField: UnusedCustomScalar + } + `; + const todoSchema = gql` + scalar UsedCustomScalar1 + + type Todo @key(fields: "todoId customField") { + todoId: Int! + text: String! + complete: Boolean! + customField: UsedCustomScalar1 + } + `; + const subgraphs = new Map(); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! + } + + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + type User implements Node @key(fields: "customField1 customField2 userId") { + "The globally unique identifier." + id: ID! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + userId: String! + } + ` + ); + }); + + it('generates valid schema for entities with multi-field, un-nested complex keys', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product @key(fields: "upc sku") { + upc: String! + sku: String! + name: String + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." + id: ID! + sku: String! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('generates valid schema for entity with nested complex keys', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc sku brand { brandId store { storeId } }") + @key(fields: "upc sku brand { brandId }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Brand { + brandId: Int! + store: Store + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "brand { __typename brandId store { __typename storeId } } sku upc") { + "The globally unique identifier." + id: ID! + brand: [Brand!]! + sku: String! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Store { + storeId: Int! + } + ` + ); + }); + + it('finds the complete schema cross-subgraph', () => { + const magazineSchema = gql` + type Magazine + @key(fields: "magazineId publisher { address { country } }") { + magazineId: String! + publisher: Publisher! + } + + type Publisher { + address: Address! + } + + type Address { + country: String! + } + `; + + const bookSchema = gql` + type Book + @key(fields: "bookId author { fullName address { postalCode } }") { + bookId: String! + title: String! + author: Author! + } + + type Author @key(fields: "authorId") { + authorId: Int! + fullName: String! + address: Address! + } + + type Address { + postalCode: String! + country: String! + } + `; + + const authorSchema = gql` + type Author @key(fields: "authorId") { + authorId: Int! + fullName: String! + address: Address! + } + + type Address { + postalCode: String! + country: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('magazine-subgraph', magazineSchema); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Address { + country: String! + postalCode: String! + } + + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + address: Address! @external + authorId: Int! + fullName: String! @external + } + + type Book implements Node @key(fields: "author { __typename address { __typename postalCode } authorId fullName } bookId") { + "The globally unique identifier." + id: ID! + author: Author! + bookId: String! + } + + type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country } }") { + "The globally unique identifier." + id: ID! + magazineId: String! + publisher: Publisher! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Publisher { + address: Address! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('ignores the selected key of an entity if another key is used as part of the complex key for another entity', () => { + const bookSchema = gql` + type Book @key(fields: "bookId") @key(fields: "isbn") { + bookId: Int! + isbn: String! + } + `; + + const authorSchema = gql` + type Author @key(fields: "book { isbn }") { + name: String! + book: Book! + } + + type Book @key(fields: "isbn") { + isbn: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('uses entire entity keys when selecting based on use in another entity complex key', () => { + const bookSchema = gql` + type Book @key(fields: "bookId") @key(fields: "isbn title") { + bookId: Int! + isbn: String! + title: String! + } + `; + + const authorSchema = gql` + type Author @key(fields: "book { isbn }") { + name: String! + book: Book! + } + + type Book @key(fields: "isbn") { + isbn: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn title") { + "The globally unique identifier." + id: ID! + isbn: String! + title: String! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('uses a compound entity key if multiple keys are used in other entity complex keys', () => { + const bookSchema = gql` + type Book @key(fields: "bookId") @key(fields: "isbn title") { + bookId: Int! + isbn: String! + title: String! + } + `; + + const authorSchema = gql` + type Author @key(fields: "book { isbn }") { + name: String! + book: Book! + } + + type Book @key(fields: "isbn") { + isbn: String! + } + `; + + const reviewSchema = gql` + type Review @key(fields: "book { bookId }") { + averageRating: Float! + book: Book! + } + + type Book @key(fields: "bookId") { + bookId: Int! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + subgraphs.set('review-subgraph', reviewSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename bookId isbn title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "bookId isbn title") { + "The globally unique identifier." + id: ID! + bookId: Int! + isbn: String! + title: String! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type Review implements Node @key(fields: "book { __typename bookId isbn title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + ` + ); + }); + + it('ignores the default selected key even when a compound entity key is created', () => { + const bookSchema = gql` + type Book + @key(fields: "bookId") + @key(fields: "isbn title") + @key(fields: "sku") { + bookId: Int! + isbn: String! + title: String! + sku: String! + } + `; + + const authorSchema = gql` + type Author @key(fields: "book { isbn }") { + name: String! + book: Book! + } + + type Book @key(fields: "isbn") { + isbn: String! + } + `; + + const reviewSchema = gql` + type Review @key(fields: "book { sku }") { + averageRating: Float! + book: Book! + } + + type Book @key(fields: "bookId") { + sku: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + subgraphs.set('review-subgraph', reviewSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn sku title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn sku title") { + "The globally unique identifier." + id: ID! + isbn: String! + sku: String! + title: String! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type Review implements Node @key(fields: "book { __typename isbn sku title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + ` + ); + }); + + it('figures out key dependencies across multiple subgraphs when there are conflicts between the default selected key and key dependencies', () => { + const bookSchema = gql` + type Book + @key(fields: "bookId") + @key(fields: "isbn title") + @key(fields: "sku") { + bookId: Int! + isbn: String! + title: String! + sku: String! + } + `; + + const authorSchema = gql` + type Author + @key(fields: "review { reviewId }") + @key(fields: "book { isbn }") { + name: String! + book: Book! + review: Review! + } + + type Book @key(fields: "isbn") { + isbn: String! + } + + type Review @key(fields: "reviewId") { + reviewId: Int! + } + `; + + const reviewSchema = gql` + type Review @key(fields: "book { sku }") @key(fields: "reviewId") { + reviewId: Int! + averageRating: Float! + book: Book! + } + + type Book @key(fields: "bookId") { + sku: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + subgraphs.set('review-subgraph', reviewSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "review { __typename reviewId }") { + "The globally unique identifier." + id: ID! + review: Review! + } + + type Book implements Node @key(fields: "bookId") { + "The globally unique identifier." + id: ID! + bookId: Int! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type Review implements Node @key(fields: "reviewId") { + "The globally unique identifier." + id: ID! + reviewId: Int! + } + ` + ); + }); + + it('applies the @external directive to non-key fields used by other entity keys', () => { + const bookSchema = gql` + type Book @key(fields: "author { name }") { + author: Author! + } + + type Author @key(fields: "authorId") { + authorId: Int! + name: String! + } + `; + + const authorSchema = gql` + type Author @key(fields: "authorId") { + authorId: Int! + name: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + authorId: Int! + name: String! @external + } + + type Book implements Node @key(fields: "author { __typename authorId name }") { + "The globally unique identifier." + id: ID! + author: Author! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('applies the @external directive to @shareable non-key fields used by other entity keys', () => { + const bookSchema = gql` + type Book @key(fields: "author { name }") { + author: Author! + } + + type Author @key(fields: "authorId") { + authorId: Int! + name: String! @shareable + } + `; + + const authorSchema = gql` + type Author @key(fields: "authorId") { + authorId: Int! + name: String! @shareable + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + authorId: Int! + name: String! @external + } + + type Book implements Node @key(fields: "author { __typename authorId name }") { + "The globally unique identifier." + id: ID! + author: Author! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('uses a custom qualifier to prefer fields', () => { + const bookSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: String! + } + `; + const authorSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: String + } + + type Author @key(fields: "book { title }") { + book: Book! + } + `; + const reviewSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: String! + } + `; + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + subgraphs.set('review-subgraph', reviewSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + nodeQualifier: (node) => { + if ( + node.kind === Kind.FIELD_DEFINITION && + node.type.kind === Kind.NON_NULL_TYPE + ) { + return false; + } + return true; + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! + title: String @external + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('falls back to picking the first found field if the provided custom qualifier fails to find a field', () => { + const bookSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: String + } + `; + const authorSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: [String] + } + + type Author @key(fields: "book { title }") { + book: Book! + } + `; + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + nodeQualifier: (node) => { + if ( + node.kind === Kind.FIELD_DEFINITION && + node.type.kind !== Kind.NON_NULL_TYPE + ) { + return false; + } + return true; + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! + title: String @external + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('stops compound key generation recursion when an already-visited ancestor is encountered', () => { + const bookSchema = gql` + type Book @key(fields: "author { name }") { + author: Author! + title: String! + } + + type Author @key(fields: "book { title }") { + book: Book! + name: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename author { __typename name } title }") { + "The globally unique identifier." + id: ID! + book: Book! + name: String! @external + } + + type Book implements Node @key(fields: "author { __typename book { __typename title } name }") { + "The globally unique identifier." + id: ID! + author: Author! + title: String! @external + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('does not duplicate fields needed by other entities for their complex keys', () => { + const bookSchema = gql` + type Book @key(fields: "bookId") @key(fields: "isbn") { + bookId: String! + isbn: String! + } + `; + + const authorSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } + + type Author @key(fields: "book { isbn }") { + book: Book! + } + `; + + const reviewSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } + + type Review @key(fields: "book { isbn }") { + book: Book! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + subgraphs.set('review-subgraph', reviewSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type Review implements Node @key(fields: "book { __typename isbn }") { + "The globally unique identifier." + id: ID! + book: Book! + } + ` + ); + }); + + it('applies tags to all core relay object identification types', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } + ` + ); + }); + + it('identifies tags from field arguments', () => { + const urlSchema = gql` + type TypeA @key(fields: "selections { selectionId }") { + selections: [TypeB!] @inaccessible + fieldWithArgument(argument: Int @tag(name: "storefront")): Boolean + } + + type TypeB @key(fields: "selectionId", resolvable: false) { + selectionId: String! + } + `; + + const altSchema = gql` + type TypeB @key(fields: "selectionId") { + selectionId: String! @tag(name: "storefront") + } + `; + + const subgraphs = new Map(); + subgraphs.set('url-subgraph', urlSchema); + subgraphs.set('alt-subgraph', altSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type TypeA implements Node @key(fields: "selections { __typename selectionId }") { + "The globally unique identifier." + id: ID! @tag(name: "storefront") + selections: [TypeB!] + } + + type TypeB implements Node @key(fields: "selectionId") { + "The globally unique identifier." + id: ID! @tag(name: "storefront") + selectionId: String! + } + ` + ); + }); + + it('applies tags to the id field based on tags of sibling fields across subgraph schemas', () => { + const productSchema = gql` + type Query { + user(id: String): User + } + + type Product @key(fields: "upc") { + internalUpc: String @tag(name: "internal") + upc: String! @tag(name: "storefront") @tag(name: "internal") + name: String @tag(name: "storefront") @tag(name: "internal") + price: Int @tag(name: "storefront") @tag(name: "internal") + weight: Int @tag(name: "storefront") + } + + type Brand @key(fields: "brandId") { + brandId: Int! @tag(name: "storefront") @tag(name: "internal") + name: String @tag(name: "storefront") @tag(name: "internal") + } + + type StorefrontUser @key(fields: "userId") { + userId: String! @tag(name: "storefront") @tag(name: "internal") + name: String! @tag(name: "storefront") + } + + type InternalUser @key(fields: "userId") { + userId: String! @tag(name: "internal") + name: String! @tag(name: "internal") + } + `; + + const todoSchema = gql` + type StorefrontUser @key(fields: "userId") { + userId: String! + todos: [Todo!]! @tag(name: "internal") + } + + type Todo @key(fields: "todoId") { + todoId: Int! @tag(name: "internal") + assignedTo: InternalUser! @tag(name: "internal") + title: String! @tag(name: "internal") + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + subgraphs.set('todo-subgraph', todoSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") + brandId: Int! + } + + type InternalUser implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") + userId: String! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } + + type StorefrontUser implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") + userId: String! + } + + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") + todoId: Int! + } + ` + ); + }); + + it('uses an entity key regardless of tagging or accessibility, and accurately tags the id field', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product @key(fields: "upc") @key(fields: "name") { + upc: String! @inaccessible + name: String @tag(name: "storefront") + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! @tag(name: "storefront") + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } + ` + ); + }); + + it('applies global tags to included custom scalar definitions', () => { + const userSchema = gql` + scalar UsedCustomScalar1 + scalar UsedCustomScalar2 + scalar UnusedCustomScalar + + enum UsedEnum { + VALUE_ONE @customDirective + VALUE_TWO @customDirective @inaccessible + VALUE_THREE + } + + type Query { + user(id: String): User + } + + type User + @key( + fields: "userId customField1 customField2 customEnum1 customEnum2" + ) { + userId: String! + name: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + customEnum1: UsedEnum + customEnum2: [UsedEnum!]! + unusedField: UnusedCustomScalar + } + `; + const todoSchema = gql` + scalar UsedCustomScalar1 + + type Todo @key(fields: "todoId customField") { + todoId: Int! + text: String! + complete: Boolean! + customField: UsedCustomScalar1 + } + `; + const subgraphs = new Map(); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! + } + + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + enum UsedEnum { + VALUE_ONE + VALUE_THREE + VALUE_TWO @inaccessible + } + + type User implements Node @key(fields: "customEnum1 customEnum2 customField1 customField2 userId") { + "The globally unique identifier." + id: ID! + customEnum1: UsedEnum + customEnum2: [UsedEnum!]! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + userId: String! + } + ` + ); + }); + + it('ignores keys that use the `id` field', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + + type Brand @key(fields: "id") { + id: ID! + name: String + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('ignores interface objects', () => { + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + } + + type PrintedMedia @interfaceObject @key(fields: "mediaId") { + mediaId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('ignores types that are provided as exceptions to generation', () => { + const userSchema = gql` + type Query { + user(id: String): User + } + + type User @key(fields: "userId") { + userId: String! + name: String! + } + `; + + const todoSchema = gql` + type Todo @key(fields: "todoId") { + todoId: Int! + text: String! + complete: Boolean! + } + `; + const subgraphs = new Map(); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: [], + typeExceptions: ['Todo'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } + ` + ); + }); + + it('ignores types based on a custom qualifier function', () => { + const userSchema = gql` + type Query { + user(id: String): User + } + + type User @key(fields: "userId") { + userId: String! + name: String! + } + + type Todo @key(fields: "oldTodoKey") { + oldTodoKey: String! + } + `; + + const todoSchema = gql` + type Todo @key(fields: "todoId") @key(fields: "oldTodoKey") { + todoId: Int! + oldTodoKey: String! + text: String! + complete: Boolean! + } + `; + const subgraphs = new Map(); + subgraphs.set('todo-subgraph', todoSchema); + subgraphs.set('user-subgraph', userSchema); + + const nodeQualifier = (node) => + node.name.value !== 'Todo' || + node.directives.filter((directive) => directive.name.value === 'key') + .length > 1; + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: [], + typeExceptions: [], + nodeQualifier, + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: Int! + } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } + ` + ); + }); + + it('ignores descriptions for schema that is not owned by the FROID subgraph', () => { + const userSchema = gql` + "Scalar description" + scalar UsedCustomScalar1 + + """ + Another scalar description + """ + scalar UsedCustomScalar2 + + scalar UnusedCustomScalar + + type Query { + user(id: String): User + } + + "User description" + type User @key(fields: "userId address { postalCode }") { + "userId description" + userId: String! + "Name description" + name: String! + "Unused field description" + unusedField: UnusedCustomScalar + "Address field description" + address: Address! + } + + """ + Address type description + """ + type Address { + "postalCode field description" + postalCode: String! + } + `; + + const todoSchema = gql` + scalar UsedCustomScalar1 + + """ + Todo type description + """ + type Todo @key(fields: "todoId customField") { + "todoId field description" + todoId: Int! + text: String! + complete: Boolean! + customField: UsedCustomScalar1 + } + `; + + const subgraphs = new Map(); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Address { + postalCode: String! + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! + } + + scalar UsedCustomScalar1 + + type User implements Node @key(fields: "address { __typename postalCode } userId") { + "The globally unique identifier." + id: ID! + address: Address! + userId: String! + } + ` + ); + }); + + it('ignores the existing relay subgraph when generating types', () => { + const userSchema = gql` + type Query { + user(id: String): User + } + + type User @key(fields: "userId") { + userId: String! + name: String! + } + `; + const todoSchema = gql` + type Todo @key(fields: "todoId") { + todoId: Int! + text: String! + complete: Boolean! + } + + type User @key(fields: "userId", resolvable: false) { + userId: String! + } + `; + // prettier-ignore + const relaySchema = gql` + type AnotherType implements Node @key(fields: "someId") { + "The globally unique identifier." + id: ID! + someId: Int! + } + + directive @tag( + name: String! + ) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: Int! + } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } + `; + const subgraphs = new Map(); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); + subgraphs.set('relay-subgraph', relaySchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: Int! + } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } + ` + ); + }); + + it('does not propagate miscellaneous directives to the generated id field', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product @key(fields: "upc") { + upc: String! @someDirective + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('can uses a custom key sorter to prefer the first complex key', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc sku") + @key(fields: "brand { brandId store { storeId } }") + @key(fields: "price") + @key(fields: "brand { name }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } + + type Brand { + brandId: Int! + store: Store + name: String! + } + + type Store { + storeId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + keySorter: (keys) => { + return keys.sort((a, b) => { + return b.depth - a.depth; + }); + }, + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Brand { + brandId: Int! + store: Store + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "brand { __typename brandId store { __typename storeId } }") { + "The globally unique identifier." + id: ID! + brand: [Brand!]! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + type Store { + storeId: Int! + } + ` + ); + }); + + it('can uses a custom key sorter to prefer the first ordinal key', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc") + @key(fields: "upc sku brand { brandId store { storeId } }") + @key(fields: "upc sku") + @key(fields: "sku brand { brandId store { storeId } }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + keySorter: (keys) => keys, + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "upc") { + "The globally unique identifier." + id: ID! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('can uses a custom key sorter to prefer complex keys only when the node is named "Book"', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc sku") + @key(fields: "upc sku brand { brandId }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } + + type Brand { + brandId: Int! + store: Store + } + + type Book + @key(fields: "bookId") + @key(fields: "bookId author { authorId }") { + bookId: String! + author: Author! + } + + type Author { + authorId: String! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + keySorter: (keys, node) => { + if (node.name.value === 'Book') { + return keys.sort((a, b) => b.depth - a.depth); + } + return keys; + }, + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author { + authorId: String! + } + + type Book implements Node @key(fields: "author { __typename authorId } bookId") { + "The globally unique identifier." + id: ID! + author: Author! + bookId: String! + } + + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } + + type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." + id: ID! + sku: String! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); + + it('generates schema document AST', () => { + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const froid = new FroidSchema( + 'relay-subgraph', + FED2_DEFAULT_VERSION, + subgraphs, + {} + ); + + expect(froid.toAst().kind).toEqual(Kind.DOCUMENT); + }); + + it('honors a 2.x federation version', () => { + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: 'v2.3', + }); + + expect(actual).toMatch( + 'extend schema @link(url: "https://specs.apollo.dev/federation/v2.3"' + ); + }); + + it('throws an error if the version is not a valid v2.x version', () => { + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + let errorMessage = ''; + try { + generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: 'v3.1', + }); + } catch (err) { + errorMessage = err.message; + } + + expect(errorMessage).toMatch( + `Federation version must be a valid 'v2.x' version` + ); + }); + + describe('createLinkSchemaExtension() method', () => { + it('throws an error if no links are provided', () => { + let errorMessage = ''; + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); + + try { + const froid = new FroidSchema( + 'relay-subgraph', + FED2_DEFAULT_VERSION, + subgraphs, + {} + ); + // @ts-ignore + froid.createLinkSchemaExtension([]); + } catch (error) { + errorMessage = error.message; + } + expect(errorMessage).toEqual('At least one import must be provided.'); + }); + }); +}); diff --git a/src/schema/__tests__/Key.test.ts b/src/schema/__tests__/Key.test.ts new file mode 100644 index 0000000..5101f80 --- /dev/null +++ b/src/schema/__tests__/Key.test.ts @@ -0,0 +1,17 @@ +import {Key} from '../Key'; + +describe('Key class', () => { + it('handles duplicate key fields', () => { + const key = new Key('Book', 'bookId bookId'); + expect(key.toString()).toEqual('bookId'); + }); + + it('handles new field branches when merging keys', () => { + const first = new Key('Book', 'author { name }'); + const second = new Key('Book', 'genre { name }'); + first.merge(second); + expect(first.toString()).toEqual( + 'author { __typename name } genre { __typename name }' + ); + }); +}); diff --git a/src/schema/__tests__/createLinkSchemaExtension.test.ts b/src/schema/__tests__/createLinkSchemaExtension.test.ts new file mode 100644 index 0000000..55a1259 --- /dev/null +++ b/src/schema/__tests__/createLinkSchemaExtension.test.ts @@ -0,0 +1,40 @@ +import {ListValueNode, SchemaExtensionNode, StringValueNode} from 'graphql'; +import {createLinkSchemaExtension} from '../createLinkSchemaExtension'; +import {DEFAULT_FEDERATION_LINK_IMPORTS} from '../constants'; + +const getImports = (schema: SchemaExtensionNode): string[] => { + return (schema.directives + ?.flatMap((directive) => { + return directive.arguments?.flatMap((argument) => { + if (argument.name.value !== 'import') { + return; + } + return (argument.value as ListValueNode).values.flatMap( + (value) => (value as StringValueNode).value + ); + }); + }) + .filter(Boolean) || []) as string[]; +}; + +describe('createLinkSchemaExtension()', () => { + it('defaults to a known set of imports', () => { + const result = createLinkSchemaExtension(); + expect(getImports(result)).toEqual(DEFAULT_FEDERATION_LINK_IMPORTS); + }); + + it('applies missing `@`s to imports', () => { + const result = createLinkSchemaExtension(['apple', 'banana', '@carrot']); + expect(getImports(result)).toEqual(['@apple', '@banana', '@carrot']); + }); + + it('throws an error if no imports are provided', () => { + let errorMessage = ''; + try { + createLinkSchemaExtension([]); + } catch (error) { + errorMessage = error.message; + } + expect(errorMessage).toEqual('At least one import must be provided.'); + }); +}); diff --git a/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts b/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts index 42b9373..88ddacc 100644 --- a/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts +++ b/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts @@ -77,6 +77,7 @@ describe('generateFroidSchema for federation v1', () => { } type Brand @key(fields: "id") { + "The globally unique identifier." id: ID! name: String } @@ -92,20 +93,27 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } extend type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -132,20 +140,27 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } extend type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -175,21 +190,28 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type Product implements Node @key(fields: "upc sku") { + extend type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - upc: String! @external sku: String! @external + upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -233,21 +255,28 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type Product implements Node @key(fields: "upc sku") { + extend type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - upc: String! @external sku: String! @external + upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -287,31 +316,38 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node + type Brand { + brandId: Int! + store: Store } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type Product implements Node @key(fields: "upc sku brand { brandId store { storeId } }") { + extend type Product implements Node @key(fields: "brand { brandId store { storeId } } sku upc") { + "The globally unique identifier." id: ID! - upc: String! @external - sku: String! @external brand: [Brand!]! @external + sku: String! @external + upc: String! @external } - type Brand { - brandId: Int! - store: Store + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } type Store { storeId: Int! } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -358,31 +394,38 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node + type Brand { + brandId: Int! + store: Store } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type Product implements Node @key(fields: "upc sku brand { brandId store { storeId } }") { + extend type Product implements Node @key(fields: "brand { brandId store { storeId } } sku upc") { + "The globally unique identifier." id: ID! - upc: String! @external - sku: String! @external brand: [Brand!]! @external + sku: String! @external + upc: String! @external } - type Brand { - brandId: Int! - store: Store + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } type Store { storeId: Int! } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -427,20 +470,27 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } extend type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -495,31 +545,39 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + type Author { + authorId: String! + } - type Query { - node(id: ID!): Node + extend type Book implements Node @key(fields: "author { authorId } bookId") { + "The globally unique identifier." + id: ID! + author: Author! @external + bookId: String! @external } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type Product implements Node @key(fields: "upc sku") { + extend type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - upc: String! @external sku: String! @external + upc: String! @external } - extend type Book implements Node @key(fields: "bookId author { authorId }") { - id: ID! - bookId: String! @external - author: Author! @external + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - type Author { - authorId: String! - } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -601,25 +659,34 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node - } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type User implements Node @key(fields: "userId") { - id: ID! - userId: String! @external + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + extend type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! @external } + + extend type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! @external + } ` ); }); @@ -661,27 +728,35 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node + extend type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." + id: ID! + brandId: Int! @external } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - extend type Brand implements Node @key(fields: "brandId") { + extend type Product implements Node @key(fields: "brand { brandId } sku upc") { + "The globally unique identifier." id: ID! - brandId: Int! @external + brand: [Brand!]! @external + sku: String! @external + upc: String! @external } - extend type Product implements Node @key(fields: "upc sku brand { brandId }") { - id: ID! - upc: String! @external - sku: String! @external - brand: [Brand!]! @external + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -718,17 +793,24 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - interface Node { - id: ID! - } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! @external } @@ -777,22 +859,30 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - interface Node { - id: ID! - } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! @external } extend type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! @external } @@ -821,27 +911,33 @@ describe('generateFroidSchema for federation v1', () => { const relaySchema = gql` directive @tag( name: String! - ) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node("A globally unique entity identifier." id: ID!): Node } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } extend type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! @external } extend type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! @external } extend type AnotherType implements Node @key(fields: "someId") { + "The globally unique identifier." id: ID! someId: Int! @external } @@ -859,25 +955,33 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - interface Node { + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + + extend type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! + todoId: Int! @external } extend type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! @external } - - extend type Todo implements Node @key(fields: "todoId") { - id: ID! - todoId: Int! @external - } ` ); }); @@ -922,31 +1026,39 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - scalar UsedCustomScalar1 - - scalar UsedCustomScalar2 + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - interface Node { + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + + extend type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." id: ID! + customField: UsedCustomScalar1 @external + todoId: Int! @external } - extend type User implements Node @key(fields: "userId customField1 customField2") { + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + extend type User implements Node @key(fields: "customField1 customField2 userId") { + "The globally unique identifier." id: ID! - userId: String! @external customField1: UsedCustomScalar1 @external customField2: [UsedCustomScalar2!]! @external - } - - extend type Todo implements Node @key(fields: "todoId customField") { - id: ID! - todoId: Int! @external - customField: UsedCustomScalar1 @external + userId: String! @external } ` ); @@ -978,20 +1090,27 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") - } - + "The global identification interface implemented by all entities." interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." id: ID! } extend type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -1021,20 +1140,27 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION - - type Query { - node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") - } - + "The global identification interface implemented by all entities." interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." id: ID! } extend type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! @tag(name: "storefront") upc: String! @external } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); @@ -1094,37 +1220,48 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + extend type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") + brandId: Int! @external + } - type Query { - node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") + extend type InternalUser implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") + userId: String! @external } + "The global identification interface implemented by all entities." interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." id: ID! } extend type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! @tag(name: "internal") @tag(name: "storefront") upc: String! @external } - extend type Brand implements Node @key(fields: "brandId") { - id: ID! @tag(name: "internal") @tag(name: "storefront") - brandId: Int! @external + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") } extend type StorefrontUser implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! @tag(name: "internal") @tag(name: "storefront") userId: String! @external } - extend type InternalUser implements Node @key(fields: "userId") { - id: ID! @tag(name: "internal") - userId: String! @external - } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! @tag(name: "internal") todoId: Int! @external } @@ -1179,7 +1316,28 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + + extend type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 @external + todoId: Int! @external + } scalar UsedCustomScalar1 @@ -1190,26 +1348,13 @@ describe('generateFroidSchema for federation v1', () => { VALUE_TWO } - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - - interface Node @tag(name: "internal") @tag(name: "storefront") { + extend type User implements Node @key(fields: "customEnum customField1 customField2 userId") { + "The globally unique identifier." id: ID! - } - - extend type User implements Node @key(fields: "userId customField1 customField2 customEnum") { - id: ID! - userId: String! @external + customEnum: UsedEnum @external customField1: UsedCustomScalar1 @external customField2: [UsedCustomScalar2!]! @external - customEnum: UsedEnum @external - } - - extend type Todo implements Node @key(fields: "todoId customField") { - id: ID! - todoId: Int! @external - customField: UsedCustomScalar1 @external + userId: String! @external } ` ); @@ -1246,22 +1391,30 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") } - interface Node @tag(name: "internal") @tag(name: "storefront") { - id: ID! - } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type TypeA implements Node @key(fields: "selections { selectionId }") { + "The globally unique identifier." id: ID! @tag(name: "storefront") selections: [TypeB!] @external } extend type TypeB implements Node @key(fields: "selectionId") { + "The globally unique identifier." id: ID! @tag(name: "storefront") selectionId: String! @external } @@ -1335,43 +1488,52 @@ describe('generateFroidSchema for federation v1', () => { expect(actual).toEqual( // prettier-ignore gql` - directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION + type Address { + country: String! + postalCode: String! + } - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") + extend type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + address: Address! @external + authorId: Int! @external + fullName: String! @external } - interface Node @tag(name: "internal") @tag(name: "storefront") { + extend type Book implements Node @key(fields: "author { address { postalCode } fullName } bookId") { + "The globally unique identifier." id: ID! + author: Author! @external + bookId: String! @external } extend type Magazine implements Node @key(fields: "magazineId publisher { address { country } }") { + "The globally unique identifier." id: ID! magazineId: String! @external publisher: Publisher! @external } - type Publisher { - address: Address! + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! } - type Address { - country: String! - postalCode: String! + type Publisher { + address: Address! } - extend type Book implements Node @key(fields: "bookId author { fullName address { postalCode } }") { - id: ID! - bookId: String! @external - author: Author! @external + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") } - extend type Author implements Node @key(fields: "authorId") { - id: ID! - fullName: String! @external - address: Address! @external - authorId: Int! @external - } + directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION ` ); }); diff --git a/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts b/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts index 0f5bd88..abe2818 100644 --- a/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts +++ b/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts @@ -122,6 +122,7 @@ describe('generateFroidSchema for federation v2', () => { } type Brand @key(fields: "id") { + "The globally unique identifier." id: ID! name: String } @@ -139,18 +140,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } ` ); }); @@ -179,18 +187,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } ` ); }); @@ -222,18 +237,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type Product implements Node @key(fields: "upc sku") { + type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - upc: String! sku: String! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } ` ); @@ -280,18 +302,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type Product implements Node @key(fields: "upc sku") { + type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - upc: String! sku: String! + upc: String! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } ` ); @@ -341,24 +370,31 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node + type Brand { + brandId: Int! + store: Store } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type Product implements Node @key(fields: "upc sku brand { brandId store { storeId } }") { + type Product implements Node @key(fields: "brand { brandId store { storeId } } sku upc") { + "The globally unique identifier." id: ID! - upc: String! - sku: String! brand: [Brand!]! + sku: String! + upc: String! } - type Brand { - brandId: Int! - store: Store + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } type Store { @@ -410,18 +446,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } ` ); }); @@ -478,28 +521,36 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node + type Author { + authorId: String! } - interface Node { + type Book implements Node @key(fields: "author { authorId } bookId") { + "The globally unique identifier." id: ID! + author: Author! + bookId: String! } - type Product implements Node @key(fields: "upc sku") { + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." id: ID! - upc: String! - sku: String! } - type Book implements Node @key(fields: "bookId author { authorId }") { + type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - bookId: String! - author: Author! + sku: String! + upc: String! } - type Author { - authorId: String! + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } ` ); @@ -544,24 +595,31 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node + type Brand { + brandId: Int! + store: Store } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type Product implements Node @key(fields: "upc sku brand { brandId store { storeId } }") { + type Product implements Node @key(fields: "brand { brandId store { storeId } } sku upc") { + "The globally unique identifier." id: ID! - upc: String! - sku: String! brand: [Brand!]! + sku: String! + upc: String! } - type Brand { - brandId: Int! - store: Store + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } type Store { @@ -650,23 +708,31 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type User implements Node @key(fields: "userId") { - id: ID! - userId: String! + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } ` ); }); @@ -710,24 +776,32 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node + type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." + id: ID! + brandId: Int! } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type Brand implements Node @key(fields: "brandId") { + type Product implements Node @key(fields: "brand { brandId } sku upc") { + "The globally unique identifier." id: ID! - brandId: Int! + brand: [Brand!]! + sku: String! + upc: String! } - type Product implements Node @key(fields: "upc sku brand { brandId }") { - id: ID! - upc: String! - sku: String! - brand: [Brand!]! + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } ` ); @@ -768,15 +842,22 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! } @@ -830,20 +911,28 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! } type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! } @@ -879,24 +968,30 @@ describe('generateFroidSchema for federation v2', () => { ) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node("A globally unique entity identifier." id: ID!): Node } + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! userId: String! } type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! } type AnotherType implements Node @key(fields: "someId") { + "The globally unique identifier." id: ID! someId: Int! } @@ -916,23 +1011,31 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } - + "The global identification interface implemented by all entities." interface Node { + "The globally unique identifier." id: ID! } - type User implements Node @key(fields: "userId") { - id: ID! - userId: String! + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! todoId: Int! } + + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } ` ); }); @@ -979,29 +1082,37 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - scalar UsedCustomScalar1 - - scalar UsedCustomScalar2 + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } type Query { - node(id: ID!): Node + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - interface Node { + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." id: ID! + customField: UsedCustomScalar1 + todoId: Int! } - type User implements Node @key(fields: "userId customField1 customField2") { + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + type User implements Node @key(fields: "customField1 customField2 userId") { + "The globally unique identifier." id: ID! - userId: String! customField1: UsedCustomScalar1 customField2: [UsedCustomScalar2!]! - } - - type Todo implements Node @key(fields: "todoId customField") { - id: ID! - todoId: Int! - customField: UsedCustomScalar1 + userId: String! } ` ); @@ -1035,18 +1146,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") - } - + "The global identification interface implemented by all entities." interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." id: ID! } type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! upc: String! } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } ` ); }); @@ -1078,18 +1196,25 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") - } - + "The global identification interface implemented by all entities." interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." id: ID! } type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! @tag(name: "storefront") upc: String! } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") + } ` ); }); @@ -1151,35 +1276,46 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") + type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") + brandId: Int! } + type InternalUser implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") + userId: String! + } + + "The global identification interface implemented by all entities." interface Node @tag(name: "storefront") @tag(name: "supplier") { + "The globally unique identifier." id: ID! } type Product implements Node @key(fields: "upc") { + "The globally unique identifier." id: ID! @tag(name: "internal") @tag(name: "storefront") upc: String! } - type Brand implements Node @key(fields: "brandId") { - id: ID! @tag(name: "internal") @tag(name: "storefront") - brandId: Int! + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "storefront") @tag(name: "supplier") } type StorefrontUser implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! @tag(name: "internal") @tag(name: "storefront") userId: String! } - type InternalUser implements Node @key(fields: "userId") { - id: ID! @tag(name: "internal") - userId: String! - } - type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." id: ID! @tag(name: "internal") todoId: Int! } @@ -1241,37 +1377,45 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! + } + scalar UsedCustomScalar1 scalar UsedCustomScalar2 enum UsedEnum { VALUE_ONE - VALUE_TWO @inaccessible VALUE_THREE + VALUE_TWO @inaccessible } - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - - interface Node @tag(name: "internal") @tag(name: "storefront") { - id: ID! - } - - type User implements Node @key(fields: "userId customField1 customField2 customEnum1 customEnum2") { + type User implements Node @key(fields: "customEnum1 customEnum2 customField1 customField2 userId") { + "The globally unique identifier." id: ID! - userId: String! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! customEnum1: UsedEnum customEnum2: [UsedEnum!]! - } - - type Todo implements Node @key(fields: "todoId customField") { - id: ID! - todoId: Int! - customField: UsedCustomScalar1 + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + userId: String! } ` ); @@ -1310,20 +1454,28 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - + "The global identification interface implemented by all entities." interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." id: ID! } + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + type TypeA implements Node @key(fields: "selections { selectionId }") { + "The globally unique identifier." id: ID! @tag(name: "storefront") selections: [TypeB!] } type TypeB implements Node @key(fields: "selectionId") { + "The globally unique identifier." id: ID! @tag(name: "storefront") selectionId: String! } @@ -1399,40 +1551,49 @@ describe('generateFroidSchema for federation v2', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") + type Address { + country: String! + postalCode: String! } - interface Node @tag(name: "internal") @tag(name: "storefront") { + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + address: Address! + authorId: Int! + fullName: String! + } + + type Book implements Node @key(fields: "author { address { postalCode } fullName } bookId") { + "The globally unique identifier." id: ID! + author: Author! + bookId: String! } type Magazine implements Node @key(fields: "magazineId publisher { address { country } }") { + "The globally unique identifier." id: ID! magazineId: String! publisher: Publisher! } - type Publisher { - address: Address! - } - - type Address { - country: String! - postalCode: String! - } - - type Book implements Node @key(fields: "bookId author { fullName address { postalCode } }") { + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." id: ID! - bookId: String! - author: Author! } - type Author implements Node @key(fields: "authorId") { - id: ID! - fullName: String! + type Publisher { address: Address! - authorId: Int! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") } ` ); diff --git a/src/schema/__tests__/sortDocumentAst.test.ts b/src/schema/__tests__/sortDocumentAst.test.ts new file mode 100644 index 0000000..68b3248 --- /dev/null +++ b/src/schema/__tests__/sortDocumentAst.test.ts @@ -0,0 +1,175 @@ +import {stripIndent as gql} from 'common-tags'; +import {sortDocumentAst} from '../sortDocumentAst'; +import {parse, print} from 'graphql'; + +const sort = (schema: string): string => + print(sortDocumentAst(parse(schema, {noLocation: true}))); + +describe('sortDocumentAst()', () => { + it('sorts document AST', () => { + const schema = gql` + type Zebra { + stripesCount: Int! + eyeColor: Color! + } + + directive @caps(match: String, all: Boolean) on OBJECT | FIELD + + union Animals = Zebra | Ape + + enum Color { + ORANGE + BLUE + MAGENTA + } + + input SomeInput { + someArgument: Boolean + anotherArgument: Int! + } + + extend schema @tag(name: "blue") @tag(name: "green") + + interface Food { + flavor: String! + } + + type Ape @key(fields: "name armLength") { + name: String! @caps(match: "Bob", all: false) + armLength: Float! + } + + extend schema @tag(name: "red") @tag(name: "orange") + + scalar WingSpan + `; + + expect(sort(schema)).toEqual( + // prettier-ignore + gql` + extend schema @tag(name: "blue") @tag(name: "green") + + extend schema @tag(name: "red") @tag(name: "orange") + + union Animals = Ape | Zebra + + type Ape @key(fields: "armLength name") { + armLength: Float! + name: String! @caps(all: false, match: "Bob") + } + + directive @caps(all: Boolean, match: String) on FIELD | OBJECT + + enum Color { + BLUE + MAGENTA + ORANGE + } + + interface Food { + flavor: String! + } + + input SomeInput { + anotherArgument: Int! + someArgument: Boolean + } + + scalar WingSpan + + type Zebra { + eyeColor: Color! + stripesCount: Int! + } + ` + ); + }); + + it('sorts id fields to the top of the list', () => { + const schema = gql` + type Ape { + id: ID! + name: String! + } + + type Gorilla { + name: String! + id: ID! + } + `; + + expect(sort(schema)).toEqual( + // prettier-ignore + gql` + type Ape { + id: ID! + name: String! + } + + type Gorilla { + id: ID! + name: String! + } + ` + ); + }); + + it('sorts applied directives alphabetically by name', () => { + const schema = gql` + type Ape { + name: String! @inaccessible @caps + } + `; + + expect(sort(schema)).toEqual( + // prettier-ignore + gql` + type Ape { + name: String! @caps @inaccessible + } + ` + ); + }); + + it('sorts applied directive arguments alphabetically by name', () => { + const schema = gql` + type Ape { + name: String! @caps(match: "asdf", all: false) + } + `; + + expect(sort(schema)).toEqual( + // prettier-ignore + gql` + type Ape { + name: String! @caps(all: false, match: "asdf") + } + ` + ); + }); + + it('sorts duplicate applied directives by their arguments', () => { + const schema = gql` + type Ape { + name: String! + @tag(name: 1) + @tag(name: "avengers") + @capitalize(all: true) + @tag(name: "justice-league") + @tag(name: {bob: "Barker"}) + @capitalize(match: "ASDF", all: false) + @tag(size: "small") + @capitalize(all: false) + } + `; + + expect(sort(schema)).toEqual( + // prettier-ignore + gql` + type Ape { + name: String! @capitalize(all: true) @capitalize(all: false) @capitalize(all: false, match: "ASDF") @tag(name: 1) @tag(name: "avengers") @tag(name: "justice-league") @tag(name: {bob: "Barker"}) @tag(size: "small") + } + ` + ); + }); +}); diff --git a/src/schema/constants.ts b/src/schema/constants.ts index f44638c..3ad67cd 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -1,10 +1,47 @@ +import {ConstDirectiveNode, Kind} from 'graphql'; + export const FED1_VERSION = 'v1'; export const FED2_DEFAULT_VERSION = 'v2.0'; export const FED2_VERSION_PREFIX = 'v2.'; +export const FED2_OPT_IN_URL = 'https://specs.apollo.dev/federation/'; export const ID_FIELD_NAME = 'id'; export const ID_FIELD_TYPE = 'ID'; -export const EXTERNAL_DIRECTIVE = 'external'; -export const EXTENDS_DIRECTIVE = 'extends'; -export const TAG_DIRECTIVE = 'tag'; -export const KEY_DIRECTIVE = 'key'; -export const INTERFACE_OBJECT_DIRECTIVE = 'interfaceObject'; +export const TYPENAME_FIELD_NAME = '__typename'; + +export enum Directive { + Extends = '@extends', + External = '@external', + InterfaceObject = '@interfaceObject', + Key = '@key', + Tag = '@tag', +} + +export enum DirectiveName { + Extends = 'extends', + External = 'external', + InterfaceObject = 'interfaceObject', + Key = 'key', + Tag = 'tag', +} + +export enum KeyDirectiveArgument { + Fields = 'fields', +} + +export const CONTRACT_DIRECTIVE_NAME = DirectiveName.Tag; +export const EXTERNAL_DIRECTIVE = DirectiveName.External; +export const EXTENDS_DIRECTIVE = DirectiveName.Extends; +export const TAG_DIRECTIVE = DirectiveName.Tag; +export const KEY_DIRECTIVE = DirectiveName.Key; +export const INTERFACE_OBJECT_DIRECTIVE = DirectiveName.InterfaceObject; + +export const EXTERNAL_DIRECTIVE_AST = { + kind: Kind.DIRECTIVE, + name: {kind: Kind.NAME, value: DirectiveName.External}, +} as ConstDirectiveNode; + +export const DEFAULT_FEDERATION_LINK_IMPORTS = [ + Directive.Key, + Directive.Tag, + Directive.External, +]; diff --git a/src/schema/createIdField.ts b/src/schema/createIdField.ts index 2f4eb68..b37742e 100644 --- a/src/schema/createIdField.ts +++ b/src/schema/createIdField.ts @@ -15,6 +15,10 @@ export function createIdField( ): FieldDefinitionNode { return { kind: Kind.FIELD_DEFINITION, + description: { + kind: Kind.STRING, + value: `The globally unique identifier.`, + }, name: { kind: Kind.NAME, value: ID_FIELD_NAME, diff --git a/src/schema/createLinkSchemaExtension.ts b/src/schema/createLinkSchemaExtension.ts index 7b92840..eaa8bc1 100644 --- a/src/schema/createLinkSchemaExtension.ts +++ b/src/schema/createLinkSchemaExtension.ts @@ -1,10 +1,12 @@ import {ConstArgumentNode, Kind, SchemaExtensionNode} from 'graphql'; -import {FED2_DEFAULT_VERSION} from './constants'; - -export const FED2_OPT_IN_URL = 'https://specs.apollo.dev/federation/'; +import { + DEFAULT_FEDERATION_LINK_IMPORTS, + FED2_DEFAULT_VERSION, + FED2_OPT_IN_URL, +} from './constants'; export const createLinkSchemaExtension = ( - imports: string[] = ['@key'], + imports: string[] = DEFAULT_FEDERATION_LINK_IMPORTS, version = FED2_DEFAULT_VERSION ): SchemaExtensionNode => { if (!imports.length) { diff --git a/src/schema/createNodeInterface.ts b/src/schema/createNodeInterface.ts index 373db42..de5659f 100644 --- a/src/schema/createNodeInterface.ts +++ b/src/schema/createNodeInterface.ts @@ -15,6 +15,10 @@ export function createNodeInterface( ): InterfaceTypeDefinitionNode { return { kind: Kind.INTERFACE_TYPE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'The global identification interface implemented by all entities.', + }, name: { kind: Kind.NAME, value: 'Node', diff --git a/src/schema/createQueryDefinition.ts b/src/schema/createQueryDefinition.ts index fd3238c..449b07e 100644 --- a/src/schema/createQueryDefinition.ts +++ b/src/schema/createQueryDefinition.ts @@ -24,6 +24,10 @@ export function createQueryDefinition( fields: [ { kind: Kind.FIELD_DEFINITION, + description: { + kind: Kind.STRING, + value: 'Fetches an entity by its globally unique identifier.', + }, name: { kind: Kind.NAME, value: 'node', @@ -31,6 +35,10 @@ export function createQueryDefinition( arguments: [ { kind: Kind.INPUT_VALUE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'A globally unique entity identifier.', + }, name: { kind: Kind.NAME, value: ID_FIELD_NAME, diff --git a/src/schema/generateFroidSchema.ts b/src/schema/generateFroidSchema.ts index d5d4123..04a0486 100644 --- a/src/schema/generateFroidSchema.ts +++ b/src/schema/generateFroidSchema.ts @@ -30,6 +30,7 @@ import {createLinkSchemaExtension} from './createLinkSchemaExtension'; import {createFederationV1TagDirectiveDefinition} from './createFederationV1TagDirectiveDefinition'; import {ObjectTypeNode, KeyMappingRecord, ValidKeyDirective} from './types'; import {removeInterfaceObjects} from './removeInterfaceObjects'; +import {sortDocumentAst} from './sortDocumentAst'; type KeySorter = (keys: string[], node: ObjectTypeNode) => string[]; @@ -457,7 +458,7 @@ export function generateFroidSchema( ); // build schema - return { + return sortDocumentAst({ kind: Kind.DOCUMENT, definitions: [ tagDefinition, @@ -470,5 +471,5 @@ export function generateFroidSchema( createNodeInterface(allTagDirectives), ...Object.values(relayObjectTypes), ], - } as DocumentNode; + } as DocumentNode); } diff --git a/src/schema/sortDocumentAst.ts b/src/schema/sortDocumentAst.ts new file mode 100644 index 0000000..1660e0a --- /dev/null +++ b/src/schema/sortDocumentAst.ts @@ -0,0 +1,380 @@ +import { + ASTNode, + ArgumentNode, + ConstDirectiveNode, + DefinitionNode, + DirectiveDefinitionNode, + DirectiveNode, + DocumentNode, + EnumTypeDefinitionNode, + EnumTypeExtensionNode, + EnumValueDefinitionNode, + FieldDefinitionNode, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, + InputValueDefinitionNode, + InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + Kind, + NamedTypeNode, + ObjectTypeDefinitionNode, + ObjectTypeExtensionNode, + ScalarTypeDefinitionNode, + ScalarTypeExtensionNode, + StringValueNode, + UnionTypeDefinitionNode, + UnionTypeExtensionNode, +} from 'graphql'; +import {DirectiveName, ID_FIELD_NAME, KeyDirectiveArgument} from './constants'; +import {Key} from './Key'; + +type NamedNode = + | ArgumentNode + | DirectiveNode + | DirectiveDefinitionNode + | EnumTypeDefinitionNode + | EnumTypeExtensionNode + | EnumValueDefinitionNode + | FieldDefinitionNode + | InputObjectTypeDefinitionNode + | InputObjectTypeExtensionNode + | InputValueDefinitionNode + | InterfaceTypeDefinitionNode + | InterfaceTypeExtensionNode + | NamedTypeNode + | ObjectTypeDefinitionNode + | ObjectTypeExtensionNode + | ScalarTypeDefinitionNode + | ScalarTypeExtensionNode + | UnionTypeDefinitionNode + | UnionTypeExtensionNode; + +const NamedStandaloneNodeKinds = [ + Kind.SCALAR_TYPE_DEFINITION, + Kind.SCALAR_TYPE_EXTENSION, +]; + +const NamedParentNodeKinds = [ + Kind.DIRECTIVE_DEFINITION, + Kind.ENUM_TYPE_DEFINITION, + Kind.ENUM_TYPE_EXTENSION, + Kind.INPUT_OBJECT_TYPE_DEFINITION, + Kind.INPUT_OBJECT_TYPE_EXTENSION, + Kind.INTERFACE_TYPE_DEFINITION, + Kind.INPUT_OBJECT_TYPE_EXTENSION, + Kind.OBJECT_TYPE_DEFINITION, + Kind.OBJECT_TYPE_EXTENSION, + Kind.UNION_TYPE_DEFINITION, + Kind.UNION_TYPE_EXTENSION, +]; + +const NamedChildNodeKinds = [ + Kind.ARGUMENT, + Kind.DIRECTIVE, + Kind.ENUM_VALUE_DEFINITION, + Kind.FIELD_DEFINITION, + Kind.INPUT_VALUE_DEFINITION, +]; + +const namedNodeKinds = [ + ...NamedStandaloneNodeKinds, + ...NamedParentNodeKinds, + ...NamedChildNodeKinds, +]; + +/** + * Sorts a document + * + * @param {DocumentNode} doc - The schema document AST with definitions in need of sorting + * @returns {DocumentNode} The sorted document + */ +export function sortDocumentAst(doc: DocumentNode): DocumentNode { + return { + ...doc, + definitions: [ + ...(sortDefinitions(doc.definitions) as readonly DefinitionNode[]), + ], + }; +} + +/** + * Type guard for named nodes. + * + * @param {ASTNode} node - The node to be checked + * @returns {boolean} Whether or not the node is a named node + */ +function isNamedNode(node: ASTNode): node is NamedNode { + return namedNodeKinds.includes(node.kind); +} + +/** + * Sorts a document's definition nodes. + * + * @param {DefinitionNode} definitions - The definitions in need of sorting + * @returns {DefinitionNode} The sorted nodes + */ +function sortDefinitions( + definitions: readonly DefinitionNode[] +): readonly DefinitionNode[] { + const unnamedNodes: ASTNode[] = []; + const namedNodes: NamedNode[] = []; + + definitions.forEach((node) => { + if (isNamedNode(node)) { + namedNodes.push(sortChildren(node)); + return; + } + unnamedNodes.push(node); + }); + + unnamedNodes.sort(sortByKind); + namedNodes.sort(sortByName); + + return [...unnamedNodes, ...namedNodes] as DefinitionNode[]; +} + +/** + * Sorts a document's definition nodes. + * + * @param {NamedNode} nodes - The definitions in need of sorting + * @returns {NamedNode} The sorted nodes + */ +function sortNodes(nodes: readonly NamedNode[]): readonly NamedNode[] { + return [...nodes].sort(sortByName).map((node) => sortChildren(node)); +} + +/** + * Sorts a list of applied directives. + * + * @param {ConstDirectiveNode} directives - The directives in need of sorting + * @returns {ConstDirectiveNode} The sorted nodes + */ +function sortDirectives( + directives: readonly ConstDirectiveNode[] +): readonly ConstDirectiveNode[] { + return [...directives] + .map((node) => sortChildren(node) as ConstDirectiveNode) + .sort(sortDirectivesByNameAndValue); +} + +/** + * Sorts the children of a node. + * + * @param {NamedNode} node - The node with children that need to be sorted + * @returns {NamedNode} The sorted node + */ +function sortChildren(node: NamedNode): NamedNode { + if (node.kind === Kind.NAMED_TYPE || node.kind === Kind.ARGUMENT) { + return node; + } + + if (node.kind === Kind.DIRECTIVE) { + const args = node?.arguments ? {arguments: sortNodes(node.arguments)} : {}; + return { + ...node, + ...args, + } as NamedNode; + } + + if (node.kind === Kind.DIRECTIVE_DEFINITION) { + const args = node?.arguments ? {arguments: sortNodes(node.arguments)} : {}; + const locations = node?.locations + ? { + locations: [...node.locations].sort((a, b) => + a.value.localeCompare(b.value) + ), + } + : {}; + return { + ...node, + ...args, + ...locations, + } as NamedNode; + } + + const directives = node?.directives + ? {directives: sortDirectives(sortKeyDirectiveFields(node.directives))} + : {}; + + if ( + node.kind === Kind.ENUM_TYPE_DEFINITION || + node.kind === Kind.ENUM_TYPE_EXTENSION + ) { + const values = node?.values ? {values: sortNodes(node.values)} : {}; + return { + ...node, + ...values, + ...directives, + } as NamedNode; + } + + if (node.kind === Kind.FIELD_DEFINITION) { + const args = node?.arguments ? {arguments: sortNodes(node.arguments)} : {}; + return { + ...node, + ...args, + ...directives, + } as NamedNode; + } + + if ( + node.kind === Kind.OBJECT_TYPE_DEFINITION || + node.kind === Kind.OBJECT_TYPE_EXTENSION || + node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION || + node.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION || + node.kind === Kind.INTERFACE_TYPE_DEFINITION || + node.kind === Kind.INTERFACE_TYPE_EXTENSION + ) { + const fields = node?.fields ? {fields: sortNodes(node.fields)} : {}; + return { + ...node, + ...fields, + ...directives, + } as NamedNode; + } + + if ( + node.kind === Kind.UNION_TYPE_DEFINITION || + node.kind === Kind.UNION_TYPE_EXTENSION + ) { + const types = node?.types ? {types: [...node.types].sort(sortByName)} : {}; + return { + ...node, + ...types, + ...directives, + } as NamedNode; + } + + return { + ...node, + ...directives, + } as NamedNode; +} + +/** + * Sorts the `fields` argument of any @key directives in a list of directives. + * + * @param {ConstDirectiveNode[]} directives - A list of directives that may include the @key directive + * @returns {ConstDirectiveNode[]} The list of directives after any key fields have been sorted + */ +function sortKeyDirectiveFields( + directives: readonly ConstDirectiveNode[] +): readonly ConstDirectiveNode[] { + return directives.map((directive) => { + if (directive.name.value !== DirectiveName.Key || !directive.arguments) { + return directive; + } + const fieldsArgument = directive.arguments.find((arg) => { + return arg.name.value === KeyDirectiveArgument.Fields; + }); + const fields = (fieldsArgument?.value as StringValueNode | undefined) + ?.value; + const remainingArguments = directive.arguments.filter( + (arg) => arg.name.value !== KeyDirectiveArgument.Fields + ); + + if (!fieldsArgument || !fields) { + return directive; + } + + return { + ...directive, + arguments: [ + ...remainingArguments, + { + ...fieldsArgument, + value: { + ...fieldsArgument.value, + value: Key.getSortedSelectionSetFields(fields), + }, + }, + ], + } as ConstDirectiveNode; + }); +} + +/** + * Sorting comparator using a node's kind as the sorting criteria. + * + * @param {ASTNode} a - The first node being compared + * @param {ASTNode} b - The second node being compared + * @returns {number} The ordinal adjustment to be made + */ +function sortByKind(a: ASTNode, b: ASTNode): number { + return a.kind.localeCompare(b.kind); +} + +/** + * Sorting comparator using a node's name as the sorting criteria. + * + * @param {NamedNode} a - The first node being compared + * @param {NamedNode} b - The second node being compared + * @returns {number} The ordinal adjustment to be made + */ +function sortByName(a: NamedNode, b: NamedNode): number { + if (a.name.value === ID_FIELD_NAME) { + return -1; + } + if (b.name.value === ID_FIELD_NAME) { + return 1; + } + return a.name.value.localeCompare(b.name.value); +} + +/** + * Sorting comparator using a directives name and, if it's a repeated directive, first argument as the sorting criteria. + * + * @param {ConstDirectiveNode} a - The first node being compared + * @param {ConstDirectiveNode} b - The second node being compared + * @returns {number} The ordinal adjustment to be made + */ +function sortDirectivesByNameAndValue( + a: ConstDirectiveNode, + b: ConstDirectiveNode +): number { + const aArgument = a.arguments?.[0]; + const bArgument = b.arguments?.[0]; + + // Sort by directive name if they don't match + if (a.name.value !== b.name.value || !aArgument || !bArgument) { + return a.name.value.localeCompare(b.name.value); + } + + // If the directive names match, + // sort by the name of each directive's first argument if they don't match + if (aArgument.name.value !== bArgument.name.value) { + return aArgument.name.value.localeCompare(bArgument.name.value); + } + + // If the argument names match, + // attempt to sort by the argument values + const aArgValue = aArgument.value; + const bArgValue = bArgument.value; + + // Don't try to sort by complex/value-less argument values + if ( + aArgValue.kind === Kind.OBJECT || + aArgValue.kind === Kind.NULL || + aArgValue.kind === Kind.LIST || + bArgValue.kind === Kind.OBJECT || + bArgValue.kind === Kind.NULL || + bArgValue.kind === Kind.LIST + ) { + return a.name.value.localeCompare(b.name.value); + } + + // If the argument values match, sort by number of arguments. + // We could sort by subsequent arguments, but we'll wait for a use case + // to add that complexity. + if (aArgValue.value === bArgValue.value) { + return a.arguments?.length - b.arguments?.length; + } + + // Sort by boolean argument values + if (aArgValue.kind === Kind.BOOLEAN || bArgValue.kind === Kind.BOOLEAN) { + return aArgValue.value === bArgValue.value ? 0 : aArgValue.value ? -1 : 1; + } + + // Sort by the argument value + return aArgValue.value.localeCompare(bArgValue.value); +}