From 68336be4704c3faedb69e8a4cae0b510af29dd7c Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 28 Oct 2023 00:23:05 -0400 Subject: [PATCH 01/27] feat: add additional constants --- src/schema/constants.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/schema/constants.ts b/src/schema/constants.ts index f44638c..8e97908 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -1,3 +1,5 @@ +import {ConstDirectiveNode, Kind} from 'graphql'; + export const FED1_VERSION = 'v1'; export const FED2_DEFAULT_VERSION = 'v2.0'; export const FED2_VERSION_PREFIX = 'v2.'; @@ -8,3 +10,12 @@ 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 const CONTRACT_DIRECTIVE_NAME = 'tag'; +export const EXTERNAL_DIRECTIVE_AST = { + kind: Kind.DIRECTIVE, + name: {kind: Kind.NAME, value: EXTERNAL_DIRECTIVE}, +} as ConstDirectiveNode; +export enum KeyDirectiveArgument { + Fields = 'fields', +} From f5750ebef8a95a84d7427d14e140bea5101f9ac8 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 28 Oct 2023 00:24:23 -0400 Subject: [PATCH 02/27] feat: add key classes These classes transform keys into structured objects for easier and more consistent examination. --- src/schema/Key.ts | 267 +++++++++++++++++++++++++++++++++++++++++ src/schema/KeyField.ts | 118 ++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 src/schema/Key.ts create mode 100644 src/schema/KeyField.ts diff --git a/src/schema/Key.ts b/src/schema/Key.ts new file mode 100644 index 0000000..74aa140 --- /dev/null +++ b/src/schema/Key.ts @@ -0,0 +1,267 @@ +import { + ConstDirectiveNode, + DocumentNode, + Kind, + OperationDefinitionNode, + SelectionNode, + StringValueNode, + parse, +} from 'graphql'; +import {KeyField} from './KeyField'; +import {ObjectTypeNode} from './types'; +import assert from 'assert'; +import { + KEY_DIRECTIVE, + KeyDirectiveArgument, + TYPENAME_FIELD_NAME, +} from './constants'; + +/** + * Represents an object key directive as a structures object. + */ +export class Key { + /** + * The name of the object this key is associated with. + */ + public readonly typename: string; + /** + * The key's fields. + */ + private _fields: KeyField[] = []; + + /** + * Creates a key from the first key directive of an ObjectTypeNode. + * + * @param {ObjectTypeNode} objectType - The object node the key will be generated from + */ + constructor(objectType: ObjectTypeNode); + /** + * 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} type - An object type or its name + * @param {string|ConstDirectiveNode|undefined} keyFields - The object's key directive or fields + */ + constructor( + type: string | ObjectTypeNode, + keyFields?: string | ConstDirectiveNode + ) { + if (typeof type === 'string') { + assert( + keyFields, + `Cannot create key without both a typename and key fields. Received typename '${type}' and key fields '${keyFields}'` + ); + this.typename = type; + this.parseToFields(keyFields); + return; + } + const objectType = type; + this.typename = objectType.name.value; + this._fields = []; + + if (!objectType.directives) { + // If the object has no directives as all, we can safely skip it + return; + } + + const keyDirective = objectType.directives.find( + (directive) => directive.name.value === KEY_DIRECTIVE + ); + + if (!keyDirective) { + // If the object has no key, we can safely skip it + return; + } + + const fieldsString = Key.getKeyDirectiveFields(keyDirective); + + assert( + fieldsString, + `Encountered an @key directive with an improperly formatted "fields" argument on type "${this.typename}".` + ); + + this.parseToFields(fieldsString); + } + + /** + * 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 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: KEY_DIRECTIVE, + }, + 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; + } +} diff --git a/src/schema/KeyField.ts b/src/schema/KeyField.ts new file mode 100644 index 0000000..8b0c108 --- /dev/null +++ b/src/schema/KeyField.ts @@ -0,0 +1,118 @@ +import {FieldNode, Kind, SelectionNode} 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|KeyField} field - The field this key field will represent + */ + constructor(field: FieldNode | KeyField) { + if (field instanceof KeyField) { + this.name = KeyField.name; + this._selections = field._selections; + return; + } + this.name = field.name.value; + field.selectionSet?.selections?.forEach((selection) => + this.addSelection(selection) + ); + } + + /** + * Add a selection to the key field's child selections. + * + * @param {SelectionNode} selection - Selection AST + * @returns {void} + */ + public addSelection(selection: SelectionNode): void { + if (selection.kind !== Kind.FIELD) { + return; + } + 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) => { + if (mergeSelection.name === TYPENAME_FIELD_NAME) { + return; + } + 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; + } + + /** + * The names of the first level of this key field's child selections. + * + * @returns {string[]} - The child selection names + */ + public get selectionsList(): string[] { + return this._selections.map((selection) => selection.name); + } + + /** + * 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()), + '}', + ]; + } +} From f40a4f8a687b40490ce95aac4e889b5568491a8b Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 28 Oct 2023 00:25:34 -0400 Subject: [PATCH 03/27] feat: add a class for collating object type info This class collates object type info to make it easier and more consistent to reference, examine, and manipulate the object. --- src/schema/ObjectType.ts | 277 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/schema/ObjectType.ts diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts new file mode 100644 index 0000000..d5a3f79 --- /dev/null +++ b/src/schema/ObjectType.ts @@ -0,0 +1,277 @@ +import {FieldDefinitionNode, ObjectTypeDefinitionNode} from 'graphql'; +import {Key} from './Key'; +import {KEY_DIRECTIVE} from './constants'; +import {ObjectTypeNode} from './types'; +import {FroidSchema, KeySorter} from './FroidSchema'; +import {KeyField} from './KeyField'; + +/** + * Collates information about an object type definition node. + */ +export class ObjectType { + private _externallySelectedFields: 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 + */ + constructor( + public readonly node: ObjectTypeDefinitionNode, + private readonly froidObjectTypes: Record, + private readonly objectTypes: ObjectTypeDefinitionNode[], + private readonly extensionAndDefinitionNodes: ObjectTypeNode[], + private readonly keySorter: KeySorter + ) {} + + /** + * All occurrences of the node across all subgraph schemas. + * + * @returns {ObjectTypeNode[]} The list of occurrences + */ + public get occurrences(): ObjectTypeNode[] { + return this.extensionAndDefinitionNodes.filter( + (searchNode) => searchNode.name.value === this.node.name.value + ); + } + + /** + * All keys applied to all occurrences of the node. + * + * @returns {Key[]} The list of keys + */ + public get keys(): Key[] { + return this.occurrences.flatMap( + (occurrence) => + occurrence.directives + ?.filter((directive) => directive.name.value === KEY_DIRECTIVE) + .map((key) => new Key(this.node.name.value, key)) || [] + ); + } + + /** + * All the child fields from all occurrences of the node. + * + * @returns {FieldDefinitionNode[]} The list of fields + */ + public get allFields(): FieldDefinitionNode[] { + const fields: FieldDefinitionNode[] = []; + this.occurrences.forEach((occurrence) => + occurrence?.fields?.forEach((field) => { + if ( + fields.every( + (compareField) => compareField.name.value !== field.name.value + ) + ) { + fields.push(field); + } + }) + ); + return fields; + } + + /** + * The names of all the fields that appear the keys of the node. + * + * @returns {string[]} The list of key field names + */ + public get allKeyFieldsList(): string[] { + return [...new Set(this.keys.flatMap((key) => key.fieldsList))]; + } + + /** + * All the fields that appear in the keys of the node. + * + * @returns {FieldDefinitionNode[]} The list of key fields + */ + public get allKeyFields(): FieldDefinitionNode[] { + return this.allFields.filter((field) => + this.allKeyFieldsList.includes(field.name.value) + ); + } + + /** + * All the fields of the node that do not appear in keys. + * + * @returns {FieldDefinitionNode[]} The list of non-key fields + */ + public get allNonKeyFields(): FieldDefinitionNode[] { + return this.allFields.filter( + (field) => !this.allKeyFieldsList.includes(field.name.value) + ); + } + + /** + * The key selected for use in the FROID schema. + * + * @returns {Key|undefined} The selected key + */ + get selectedKey(): 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. + * + * Example schema: + * type Book @key(fields: "theBookAuthor { name }") { + * theBookAuthor: Author! + * } + * type Author { + * name + * } + * + * Example record: + * { "theBookAuthor": "Author" } + * + * @returns {Record} The list of fields that reference a child object and the object the field is referencing + */ + public get childObjectsInSelectedKey(): Record { + const children: Record = {}; + this.allFields.forEach((field) => { + if (!this?.selectedKey?.fieldsList.includes(field.name.value)) { + return; + } + const fieldType = FroidSchema.extractFieldType(field); + if ( + !this.objectTypes.find( + (searchType) => searchType.name.value === fieldType + ) + ) { + return; + } + children[field.name.value] = fieldType; + }); + return children; + } + + /** + * 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'] + * + * @returns {string[]} The list of field names + */ + public get directlySelectedFields(): string[] { + return this.allFields + .filter((field) => + this.selectedKey?.fieldsList.includes(field.name.value) + ) + .map((field) => field.name.value); + } + + /** + * 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 this.allFields.filter( + (field) => + this.directlySelectedFields.includes(field.name.value) || + this.externallySelectedFields.includes(field.name.value) + ); + } + + /** + * The list of selected fields that appear in any of the node's keys. + * + * @returns {FieldDefinitionNode[]} The list of key fields + */ + public get selectedKeyFields(): FieldDefinitionNode[] { + return this.selectedFields.filter((field) => + this.allKeyFieldsList.includes(field.name.value) + ); + } + + /** + * The list of selected fields that do not appear in any of the node's keys. + * + * @returns {FieldDefinitionNode[]} The list of non-key fields + */ + public get selectedNonKeyFields(): FieldDefinitionNode[] { + return this.selectedFields.filter( + (field) => !this.allKeyFieldsList.includes(field.name.value) + ); + } + + /** + * The node's key after all key fields used by other entities are added. + * + * @todo handle key recursion... + * @returns {Key|undefined} The final key. Undefined if the node is not an entity. + */ + public get finalKey(): Key | undefined { + if (!this.selectedKey) { + return; + } + const mergedKey = new Key( + this.node.name.value, + this.selectedKey.toString() + ); + const keyFromSelections = new Key( + this.node.name.value, + [...this.selectedKeyFields.map((field) => field.name.value)].join(' ') + ); + mergedKey.merge(keyFromSelections); + Object.entries(this.childObjectsInSelectedKey).forEach( + ([dependentField, dependencyType]) => { + const dependency = this.froidObjectTypes[dependencyType]; + if (!dependency.finalKey) { + return; + } + const keyToMerge = new Key( + this.node.name.value, + `${dependentField} { ${dependency.finalKey.toString()} }` + ); + mergedKey.merge(keyToMerge); + } + ); + return mergedKey; + } + + /** + * 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.push(...fields.map((field) => field.name)); + } +} From fc29153c9271ae0fac4165e9f5fce202fb839cdd Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 28 Oct 2023 00:26:48 -0400 Subject: [PATCH 04/27] feat: rewrite FROID schema generation Rewrites FROID schema generation to make the generated schema match the resolvability assumptions of Apollo Router. --- src/schema/FroidSchema.ts | 841 +++++++++++++ src/schema/__tests__/FroidSchema.test.ts | 1453 ++++++++++++++++++++++ 2 files changed, 2294 insertions(+) create mode 100644 src/schema/FroidSchema.ts create mode 100644 src/schema/__tests__/FroidSchema.test.ts diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts new file mode 100644 index 0000000..41fd17b --- /dev/null +++ b/src/schema/FroidSchema.ts @@ -0,0 +1,841 @@ +import { + ConstArgumentNode, + ConstDirectiveNode, + DefinitionNode, + DocumentNode, + EnumTypeDefinitionNode, + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + Kind, + NamedTypeNode, + ObjectTypeDefinitionNode, + ScalarTypeDefinitionNode, + SchemaExtensionNode, + StringValueNode, + parse, + print, + specifiedScalarTypes, +} from 'graphql'; +import {ObjectTypeNode} from './types'; +import { + CONTRACT_DIRECTIVE_NAME, + EXTENDS_DIRECTIVE, + EXTERNAL_DIRECTIVE_AST, + FED2_DEFAULT_VERSION, + FED2_VERSION_PREFIX, + ID_FIELD_NAME, + ID_FIELD_TYPE, + INTERFACE_OBJECT_DIRECTIVE, + KEY_DIRECTIVE, + TAG_DIRECTIVE, +} from './constants'; +import assert from 'assert'; +import {implementsNodeInterface} from './astDefinitions'; +import {FED2_OPT_IN_URL} from './createLinkSchemaExtension'; +import {Key} from './Key'; +import {KeyField} from './KeyField'; +import {ObjectType} from './ObjectType'; + +type SupportedFroidReturnTypes = + | ScalarTypeDefinitionNode + | EnumTypeDefinitionNode; + +export type KeySorter = (keys: Key[], node: ObjectTypeNode) => Key[]; +export type NodeQualifier = ( + node: DefinitionNode, + objectTypes: ObjectTypeNode[] +) => boolean; + +export type FroidSchemaOptions = { + contractTags?: string[]; + federationVersion?: 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 {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, + schemas: Map, + options: FroidSchemaOptions + ) { + this.federationVersion = options?.federationVersion ?? FED2_DEFAULT_VERSION; + + assert( + this.federationVersion.indexOf(FED2_VERSION_PREFIX) > -1, + `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 = { + kind: Kind.DOCUMENT, + definitions: [ + this.createLinkSchemaExtension(['@key', '@tag']), + ...this.createCustomReturnTypes(), + this.createQueryDefinition(), + this.createNodeInterface(), + ...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( + ({node, finalKey, selectedKeyFields, selectedNonKeyFields}) => { + let froidFields: FieldDefinitionNode[] = []; + let externalFieldDirectives: ConstDirectiveNode[] = []; + let froidInterfaces: NamedTypeNode[] = []; + if (FroidSchema.isEntity(node)) { + froidFields = [ + FroidSchema.createIdField(this.getTagDirectivesForIdField(node)), + ]; + externalFieldDirectives = [EXTERNAL_DIRECTIVE_AST]; + froidInterfaces = [implementsNodeInterface]; + } + const fields = [ + ...froidFields, + ...selectedKeyFields.map((field) => ({...field, directives: []})), + ...selectedNonKeyFields.map((field) => ({ + ...field, + directives: externalFieldDirectives, + })), + ]; + const finalKeyDirective = finalKey?.toDirective(); + return { + ...node, + interfaces: froidInterfaces, + directives: [...(finalKeyDirective ? [finalKeyDirective] : [])], + fields, + }; + } + ); + } + + /** + * 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.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.filter( + (selection) => + !existingNode.selectedKey || + !existingNode.selectedKey.fieldsList.includes(selection.name) + ) + ); + + 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[]; + } + + /** + * Returns all non-extended types with explicit ownership to a single subgraph + * + * @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 === TAG_DIRECTIVE) + .map( + (directive) => + (directive?.arguments?.[0].value as StringValueNode).value + ) + ); + }) + .filter(Boolean) + .sort() as string[]; + + const tagDirectives: ConstDirectiveNode[] = []; + const uniqueTagDirectivesNames = [...new Set(tagDirectiveNames || [])]; + + uniqueTagDirectivesNames.forEach((tagName) => { + tagDirectives.push(FroidSchema.createTagDirective(tagName)); + }); + + return tagDirectives; + } + + /** + * 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[] = ['@key'] + ): 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[] + ).filter((nonNativeScalarType) => { + const returnTypeName = nonNativeScalarType.name.value; + // Get only types that are returned in froid schema + if ( + nonNativeScalarDefinitionNames.has(returnTypeName) && + !nonNativeScalarFieldTypes.has(returnTypeName) + ) { + 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); + } else if (nonNativeScalarType.kind === Kind.SCALAR_TYPE_DEFINITION) { + 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, + name: { + kind: Kind.NAME, + value: 'node', + }, + arguments: [ + { + kind: Kind.INPUT_VALUE_DEFINITION, + 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, + 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 + */ + private 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 + */ + private static createIdField( + directives: ConstDirectiveNode[] = [] + ): FieldDefinitionNode { + return { + kind: Kind.FIELD_DEFINITION, + 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 === EXTENDS_DIRECTIVE || + // 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 === EXTENDS_DIRECTIVE + )) + ); + } + + /** + * 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 === INTERFACE_OBJECT_DIRECTIVE + )) || + ('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 + */ + private static isEntity(nodes: ObjectTypeNode[]); + /** + * 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 + */ + private static isEntity(node: ObjectTypeNode); + /** + * 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 + */ + private static isEntity(node: ObjectTypeNode | ObjectTypeNode[]): boolean { + const nodesToCheck = Array.isArray(node) ? node : [node]; + return nodesToCheck.some((node) => + node?.directives?.some( + (directive) => directive.name.value === KEY_DIRECTIVE + ) + ); + } + + /** + * 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/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts new file mode 100644 index 0000000..35dafc6 --- /dev/null +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -0,0 +1,1453 @@ +import {stripIndent as gql} from 'common-tags'; +import {FroidSchema} from '../FroidSchema'; +import {DefinitionNode} from 'graphql'; +import {ObjectTypeNode} from '../types'; +import {Key} from '../Key'; + +function generateSchema({ + subgraphs, + froidSubgraphName, + contractTags = [], + typeExceptions = [], + federationVersion, + nodeQualifier, + keySorter, +}: { + subgraphs: Map; + froidSubgraphName: string; + contractTags?: string[]; + typeExceptions?: string[]; + federationVersion?: string; + nodeQualifier?: ( + node: DefinitionNode, + objectTypes: ObjectTypeNode[] + ) => boolean; + keySorter?: (keys: Key[], node: ObjectTypeNode) => Key[]; +}) { + const froidSchema = new FroidSchema(froidSubgraphName, subgraphs, { + contractTags, + typeExceptions, + nodeQualifier, + federationVersion, + keySorter, + }); + + return froidSchema.toString(); +} + +describe('FroidSchema class', () => { + it('defaults the federation version to 2.0', () => { + 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', + }); + + expect(actual).toMatch( + 'extend schema @link(url: "https://specs.apollo.dev/federation/v2.0"' + ); + }); + + it('honors a custom 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` + ); + }); + + it('ignores @key(fields: "id") directives', () => { + 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + id: ID! + upc: 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + id: ID! + upc: String! + } + ` + ); + }); + + it('generates valid schema for entity with complex (multi-field) 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc sku") { + id: ID! + upc: String! + sku: String! + } + ` + ); + }); + + it('defaults to generating valid schema using the first non-nested complex (multi-field) 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") + @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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { + id: ID! + upc: String! + sku: String! + brand: [Brand!]! + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! + } + ` + ); + }); + + it('uses a custom key sorter to prefer complex keys', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product + @key(fields: "upc sku") + @key(fields: "upc sku brand { brandId store { storeId } }") + @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', + keySorter: (keys) => { + return keys.sort((a, b) => { + return b.depth - a.depth; + }); + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { + id: ID! + upc: String! + sku: String! + brand: [Brand!]! + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! + } + ` + ); + }); + + it('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, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + id: ID! + upc: String! + } + ` + ); + }); + + it('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; + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc sku") { + id: ID! + upc: String! + sku: String! + } + + type Book implements Node @key(fields: "bookId author { __typename authorId }") { + id: ID! + bookId: String! + author: Author! + } + + type Author { + authorId: String! + } + ` + ); + }); + + it('generates valid schema for entity with nested complex (multi-field) 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { + id: ID! + upc: String! + sku: String! + brand: [Brand!]! + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! + } + ` + ); + }); + + it('generates the correct entities across multiple subgraph services', () => { + 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node @key(fields: "userId") { + id: ID! + userId: String! + } + + type Todo implements Node @key(fields: "todoId") { + id: ID! + todoId: Int! + } + ` + ); + }); + + it('generates the correct entities across multiple subgraph services when external entities are used as complex keys', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } + + type Product @key(fields: "upc sku brand { brandId }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } + + type Brand @key(fields: "brandId", resolvable: false) { + brandId: Int! + } + `; + + const brandSchema = gql` + type Brand @key(fields: "brandId") { + brandId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('brand-subgraph', brandSchema); + subgraphs.set('product-subgraph', productSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Brand implements Node @key(fields: "brandId") { + id: ID! + brandId: Int! + } + + type Product implements Node @key(fields: "upc sku brand { __typename brandId }") { + id: ID! + upc: String! + sku: String! + brand: [Brand!]! + } + ` + ); + }); + + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node @key(fields: "userId") { + 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, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Todo implements Node @key(fields: "todoId") { + id: ID! + todoId: Int! + } + + type User implements Node @key(fields: "userId") { + id: ID! + 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! + } + `; + const relaySchema = gql` + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node @key(fields: "userId") { + id: ID! + userId: String! + } + + type Todo implements Node @key(fields: "todoId") { + id: ID! + todoId: Int! + } + + type AnotherType implements Node @key(fields: "someId") { + id: ID! + someId: Int! + } + `; + 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node @key(fields: "userId") { + id: ID! + userId: String! + } + + type Todo implements Node @key(fields: "todoId") { + id: ID! + todoId: Int! + } + ` + ); + }); + + it('generates custom scalar definitions when they are used on a type definition in the schema', () => { + 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node @key(fields: "userId customField1 customField2") { + id: ID! + userId: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + } + + type Todo implements Node @key(fields: "todoId customField") { + id: ID! + todoId: Int! + customField: UsedCustomScalar1 + } + ` + ); + }); + + describe('when using contacts with @tag', () => { + it('propogates valid 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "storefront") @tag(name: "supplier") { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + id: ID! + upc: String! + } + ` + ); + }); + + it('uses the first non-id key directive despite contract tags', () => { + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "storefront") @tag(name: "supplier") { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + id: ID! @tag(name: "storefront") + upc: String! + } + ` + ); + }); + + it('propogates tags to the id field based on tags of sibling fields across subgraphs', () => { + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "storefront") @tag(name: "supplier") { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + 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 StorefrontUser implements Node @key(fields: "userId") { + 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") { + id: ID! @tag(name: "internal") + todoId: Int! + } + ` + ); + }); + + it('generates custom scalar definitions w/global tags when they are used on a type definition in the schema', () => { + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + enum UsedEnum { + VALUE_ONE + VALUE_TWO @inaccessible + VALUE_THREE + } + + 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") { + 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 + } + ` + ); + }); + + it('tags are identified 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "internal") @tag(name: "storefront") { + id: ID! + } + + type TypeA implements Node @key(fields: "selections { __typename selectionId }") { + id: ID! @tag(name: "storefront") + selections: [TypeB!] + } + + type TypeB implements Node @key(fields: "selectionId") { + id: ID! @tag(name: "storefront") + selectionId: String! + } + ` + ); + }); + }); + + describe('when generating schema for complex keys', () => { + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "internal") @tag(name: "storefront") { + id: ID! + } + + type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country } }") { + id: ID! + magazineId: String! + publisher: Publisher! + } + + type Book implements Node @key(fields: "bookId author { __typename fullName address { __typename postalCode } authorId }") { + id: ID! + bookId: String! + author: Author! + } + + type Author implements Node @key(fields: "authorId") { + id: ID! + authorId: Int! + fullName: String! @external + address: Address! @external + } + + type Publisher { + address: Address! + } + + type Address { + country: String! + postalCode: String! + } + ` + ); + }); + }); +}); From 7051bba8bf68f7718e04e67232799291862c8e21 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 30 Oct 2023 11:31:23 -0400 Subject: [PATCH 05/27] feat: handle shareable key fields --- src/schema/FroidSchema.ts | 58 +- src/schema/Key.ts | 6 +- src/schema/ObjectType.ts | 4 +- src/schema/__tests__/FroidSchema.test.ts | 1321 ++++++++++++---------- src/schema/constants.ts | 38 +- 5 files changed, 798 insertions(+), 629 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 41fd17b..ec237f9 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -19,15 +19,13 @@ import { import {ObjectTypeNode} from './types'; import { CONTRACT_DIRECTIVE_NAME, - EXTENDS_DIRECTIVE, + DirectiveName, EXTERNAL_DIRECTIVE_AST, FED2_DEFAULT_VERSION, FED2_VERSION_PREFIX, ID_FIELD_NAME, ID_FIELD_TYPE, - INTERFACE_OBJECT_DIRECTIVE, - KEY_DIRECTIVE, - TAG_DIRECTIVE, + SHAREABLE_DIRECTIVE_AST, } from './constants'; import assert from 'assert'; import {implementsNodeInterface} from './astDefinitions'; @@ -195,6 +193,9 @@ export class FroidSchema { ({node, finalKey, selectedKeyFields, selectedNonKeyFields}) => { let froidFields: FieldDefinitionNode[] = []; let externalFieldDirectives: ConstDirectiveNode[] = []; + const shareableFieldDirectives: ConstDirectiveNode[] = [ + SHAREABLE_DIRECTIVE_AST, + ]; let froidInterfaces: NamedTypeNode[] = []; if (FroidSchema.isEntity(node)) { froidFields = [ @@ -208,7 +209,10 @@ export class FroidSchema { ...selectedKeyFields.map((field) => ({...field, directives: []})), ...selectedNonKeyFields.map((field) => ({ ...field, - directives: externalFieldDirectives, + directives: FroidSchema.isShareable(field) + ? // @todo test the shareable branch of this logic + shareableFieldDirectives + : externalFieldDirectives, })), ]; const finalKeyDirective = finalKey?.toDirective(); @@ -376,7 +380,7 @@ export class FroidSchema { ]); return taggableNodes?.flatMap((field) => field.directives - ?.filter((directive) => directive.name.value === TAG_DIRECTIVE) + ?.filter((directive) => directive.name.value === DirectiveName.Tag) .map( (directive) => (directive?.arguments?.[0].value as StringValueNode).value @@ -696,7 +700,7 @@ export class FroidSchema { !node.directives?.some( (directive) => // exclude @extends directive - directive.name.value === EXTENDS_DIRECTIVE || + directive.name.value === DirectiveName.Extends || // exclude entity references, i.e. @key(fields: "...", resolvable: false) directive.arguments?.some( (argument) => @@ -719,7 +723,7 @@ export class FroidSchema { node.kind === Kind.OBJECT_TYPE_EXTENSION || (node.kind === Kind.OBJECT_TYPE_DEFINITION && node.directives?.some( - (directive) => directive.name.value === EXTENDS_DIRECTIVE + (directive) => directive.name.value === DirectiveName.Extends )) ); } @@ -740,7 +744,8 @@ export class FroidSchema { !( ('directives' in node && node.directives?.some( - (directive) => directive.name.value === INTERFACE_OBJECT_DIRECTIVE + (directive) => + directive.name.value === DirectiveName.InterfaceObject )) || ('name' in node && node.name && @@ -824,7 +829,40 @@ export class FroidSchema { const nodesToCheck = Array.isArray(node) ? node : [node]; return nodesToCheck.some((node) => node?.directives?.some( - (directive) => directive.name.value === KEY_DIRECTIVE + (directive) => directive.name.value === DirectiveName.Key + ) + ); + } + + /** + * Check whether or not a list of nodes contains a shareable node. + * + * @param {(ObjectTypeNode | FieldDefinitionNode)[]} nodes - The nodes to check + * @returns {boolean} Whether or not any nodes are shareable + */ + private static isShareable(nodes: (ObjectTypeNode | FieldDefinitionNode)[]); + /** + * Check whether or not a node is shareable. + * + * @param {ObjectTypeNode | FieldDefinitionNode} node - A node to check + * @returns {boolean} Whether or not the node is shareable + */ + private static isShareable(node: ObjectTypeNode | FieldDefinitionNode); + /** + * Check whether or not one of more nodes is shareable. + * + * @param {(ObjectTypeNode | FieldDefinitionNode) | (ObjectTypeNode | FieldDefinitionNode)[]} node - One or more nodes to collectively check + * @returns {boolean} Whether or not any nodes are shareable + */ + private static isShareable( + node: + | (ObjectTypeNode | FieldDefinitionNode) + | (ObjectTypeNode | FieldDefinitionNode)[] + ): boolean { + const nodesToCheck = Array.isArray(node) ? node : [node]; + return nodesToCheck.some((node) => + node?.directives?.some( + (directive) => directive.name.value === DirectiveName.Shareable ) ); } diff --git a/src/schema/Key.ts b/src/schema/Key.ts index 74aa140..5af30dd 100644 --- a/src/schema/Key.ts +++ b/src/schema/Key.ts @@ -11,7 +11,7 @@ import {KeyField} from './KeyField'; import {ObjectTypeNode} from './types'; import assert from 'assert'; import { - KEY_DIRECTIVE, + DirectiveName, KeyDirectiveArgument, TYPENAME_FIELD_NAME, } from './constants'; @@ -78,7 +78,7 @@ export class Key { } const keyDirective = objectType.directives.find( - (directive) => directive.name.value === KEY_DIRECTIVE + (directive) => directive.name.value === DirectiveName.Key ); if (!keyDirective) { @@ -223,7 +223,7 @@ export class Key { kind: Kind.DIRECTIVE, name: { kind: Kind.NAME, - value: KEY_DIRECTIVE, + value: DirectiveName.Key, }, arguments: [ { diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index d5a3f79..e144e2a 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -1,6 +1,6 @@ import {FieldDefinitionNode, ObjectTypeDefinitionNode} from 'graphql'; import {Key} from './Key'; -import {KEY_DIRECTIVE} from './constants'; +import {DirectiveName} from './constants'; import {ObjectTypeNode} from './types'; import {FroidSchema, KeySorter} from './FroidSchema'; import {KeyField} from './KeyField'; @@ -47,7 +47,7 @@ export class ObjectType { return this.occurrences.flatMap( (occurrence) => occurrence.directives - ?.filter((directive) => directive.name.value === KEY_DIRECTIVE) + ?.filter((directive) => directive.name.value === DirectiveName.Key) .map((key) => new Key(this.node.name.value, key)) || [] ); } diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 35dafc6..7deadc3 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -195,50 +195,7 @@ describe('FroidSchema class', () => { ); }); - it('generates valid schema for entity with complex (multi-field) 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', - }); - - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - - type Query { - node(id: ID!): Node - } - - interface Node { - id: ID! - } - - type Product implements Node @key(fields: "upc sku") { - id: ID! - upc: String! - sku: String! - } - ` - ); - }); - + // @todo FIX THE NAME OF THIS TEST it('defaults to generating valid schema using the first non-nested complex (multi-field) keys', () => { const productSchema = gql` type Query { @@ -307,45 +264,78 @@ describe('FroidSchema class', () => { ); }); - it('uses a custom key sorter to prefer complex keys', () => { + it('generates the correct entities across multiple subgraph services', () => { const productSchema = gql` type Query { - topProducts(first: Int = 5): [Product] + user(id: String): User } - type Product - @key(fields: "upc sku") - @key(fields: "upc sku brand { brandId store { storeId } }") - @key(fields: "upc") - @key(fields: "sku brand { brandId store { storeId } }") { - upc: String! - sku: String! - name: String - brand: [Brand!]! - price: Int - weight: Int + type User @key(fields: "userId") { + userId: String! + name: String! } + `; - type Brand { - brandId: Int! - store: Store + 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 Store { - storeId: 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', - keySorter: (keys) => { - return keys.sort((a, b) => { - return b.depth - a.depth; - }); - }, }); expect(actual).toEqual( @@ -361,36 +351,26 @@ describe('FroidSchema class', () => { id: ID! } - type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { + type User implements Node @key(fields: "userId") { id: ID! - upc: String! - sku: String! - brand: [Brand!]! - } - - type Brand { - brandId: Int! - store: Store + userId: String! } - type Store { - storeId: Int! + type Todo implements Node @key(fields: "todoId") { + id: ID! + todoId: Int! } ` ); }); - it('uses a custom key sorter to prefer the first ordinal key', () => { + it('generates the correct entities across multiple subgraph services when external entities are used as complex keys', () => { 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 } }") { + type Product @key(fields: "upc sku brand { brandId }") { upc: String! sku: String! name: String @@ -399,22 +379,23 @@ describe('FroidSchema class', () => { weight: Int } - type Brand { + type Brand @key(fields: "brandId", resolvable: false) { brandId: Int! - store: Store } + `; - type Store { - storeId: Int! + const brandSchema = gql` + type Brand @key(fields: "brandId") { + brandId: Int! } `; const subgraphs = new Map(); + subgraphs.set('brand-subgraph', brandSchema); subgraphs.set('product-subgraph', productSchema); const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - keySorter: (keys) => keys, }); expect(actual).toEqual( @@ -430,59 +411,49 @@ describe('FroidSchema class', () => { id: ID! } - type Product implements Node @key(fields: "upc") { + type Brand implements Node @key(fields: "brandId") { + id: ID! + brandId: Int! + } + + type Product implements Node @key(fields: "upc sku brand { __typename brandId }") { id: ID! upc: String! + sku: String! + brand: [Brand!]! } ` ); }); - it('uses a custom key sorter to prefer complex keys only when the node is named "Book"', () => { - const productSchema = gql` + it('ignores types that are provided as exceptions to generation', () => { + const userSchema = 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 + user(id: String): User } - type Book - @key(fields: "bookId") - @key(fields: "bookId author { authorId }") { - bookId: String! - author: Author! + type User @key(fields: "userId") { + userId: String! + name: String! } + `; - type Author { - authorId: String! + const todoSchema = gql` + type Todo @key(fields: "todoId") { + todoId: Int! + text: String! + complete: Boolean! } `; const subgraphs = new Map(); - subgraphs.set('product-subgraph', productSchema); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); 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; - }, + contractTags: [], + typeExceptions: ['Todo'], }); expect(actual).toEqual( @@ -498,57 +469,53 @@ describe('FroidSchema class', () => { id: ID! } - type Product implements Node @key(fields: "upc sku") { - id: ID! - upc: String! - sku: String! - } - - type Book implements Node @key(fields: "bookId author { __typename authorId }") { + type User implements Node @key(fields: "userId") { id: ID! - bookId: String! - author: Author! - } - - type Author { - authorId: String! + userId: String! } ` ); }); - it('generates valid schema for entity with nested complex (multi-field) keys', () => { - const productSchema = gql` + it('ignores types based on a custom qualifier function', () => { + const userSchema = gql` type Query { - topProducts(first: Int = 5): [Product] + user(id: String): User } - 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 User @key(fields: "userId") { + userId: String! + name: String! } - type Brand { - brandId: Int! - store: Store + type Todo @key(fields: "oldTodoKey") { + oldTodoKey: String! } + `; - type Store { - storeId: Int! + 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('product-subgraph', productSchema); + 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, }); expect(actual).toEqual( @@ -564,27 +531,21 @@ describe('FroidSchema class', () => { id: ID! } - type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { + type Todo implements Node @key(fields: "todoId") { id: ID! - upc: String! - sku: String! - brand: [Brand!]! - } - - type Brand { - brandId: Int! - store: Store + todoId: Int! } - type Store { - storeId: Int! + type User implements Node @key(fields: "userId") { + id: ID! + userId: String! } ` ); }); - it('generates the correct entities across multiple subgraph services', () => { - const productSchema = gql` + it('ignores the existing relay subgraph when generating types', () => { + const userSchema = gql` type Query { user(id: String): User } @@ -594,63 +555,49 @@ describe('FroidSchema class', () => { 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 Todo @key(fields: "todoId") { + todoId: Int! + text: String! + complete: Boolean! } - type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String + type User @key(fields: "userId", resolvable: false) { + userId: String! } + `; + const relaySchema = gql` + directive @tag( + name: String! + ) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - type TodoEdge { - node: Todo - cursor: String! + type Query { + node(id: ID!): Node } - type Todo @key(fields: "todoId") { - todoId: Int! - text: String! - complete: Boolean! + interface Node { + id: ID! } - type Mutation { - addTodo(input: AddTodoInput!): AddTodoPayload + type User implements Node @key(fields: "userId") { + id: ID! + userId: String! } - input AddTodoInput { - text: String! - userId: ID! - clientMutationId: String + type Todo implements Node @key(fields: "todoId") { + id: ID! + todoId: Int! } - type AddTodoPayload { - todoEdge: TodoEdge! - user: User! - clientMutationId: String + type AnotherType implements Node @key(fields: "someId") { + id: ID! + someId: Int! } `; const subgraphs = new Map(); - subgraphs.set('product-subgraph', productSchema); + subgraphs.set('user-subgraph', userSchema); subgraphs.set('todo-subgraph', todoSchema); + subgraphs.set('relay-subgraph', relaySchema); const actual = generateSchema({ subgraphs, @@ -683,34 +630,37 @@ describe('FroidSchema class', () => { ); }); - it('generates the correct entities across multiple subgraph services when external entities are used as complex keys', () => { - const productSchema = gql` - type Query { - topProducts(first: Int = 5): [Product] - } + it('generates custom scalar definitions when they are used on a type definition in the schema', () => { + const userSchema = gql` + scalar UsedCustomScalar1 + scalar UsedCustomScalar2 + scalar UnusedCustomScalar - type Product @key(fields: "upc sku brand { brandId }") { - upc: String! - sku: String! - name: String - brand: [Brand!]! - price: Int - weight: Int + type Query { + user(id: String): User } - type Brand @key(fields: "brandId", resolvable: false) { - brandId: Int! + type User @key(fields: "userId customField1 customField2") { + userId: String! + name: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + unusedField: UnusedCustomScalar } `; + const todoSchema = gql` + scalar UsedCustomScalar1 - const brandSchema = gql` - type Brand @key(fields: "brandId") { - brandId: Int! + type Todo @key(fields: "todoId customField") { + todoId: Int! + text: String! + complete: Boolean! + customField: UsedCustomScalar1 } `; const subgraphs = new Map(); - subgraphs.set('brand-subgraph', brandSchema); - subgraphs.set('product-subgraph', productSchema); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); const actual = generateSchema({ subgraphs, @@ -722,6 +672,10 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + type Query { node(id: ID!): Node } @@ -730,308 +684,375 @@ describe('FroidSchema class', () => { id: ID! } - type Brand implements Node @key(fields: "brandId") { + type User implements Node @key(fields: "userId customField1 customField2") { id: ID! - brandId: Int! + userId: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! } - type Product implements Node @key(fields: "upc sku brand { __typename brandId }") { + type Todo implements Node @key(fields: "todoId customField") { id: ID! - upc: String! - sku: String! - brand: [Brand!]! + todoId: Int! + customField: UsedCustomScalar1 } ` ); }); - it('ignores types that are provided as exceptions to generation', () => { - const userSchema = gql` - type Query { - user(id: String): User - } + describe('when using contacts with @tag', () => { + it('propogates valid tags to all core relay object identification types', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } - type User @key(fields: "userId") { - userId: String! - name: String! - } - `; + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); - 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: ['storefront', 'supplier'], + }); - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: [], - typeExceptions: ['Todo'], - }); + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - expect(actual).toEqual( - // prettier-ignore - 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 Query { - node(id: ID!): Node - } + interface Node @tag(name: "storefront") @tag(name: "supplier") { + id: ID! + } - interface Node { - id: ID! - } + type Product implements Node @key(fields: "upc") { + id: ID! + upc: String! + } + ` + ); + }); - type User implements Node @key(fields: "userId") { - id: ID! - userId: String! + it('uses the first non-id key directive despite contract tags', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] } - ` - ); - }); - it('ignores types based on a custom qualifier function', () => { - const userSchema = gql` - type Query { - user(id: String): User - } + 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); - type User @key(fields: "userId") { - userId: String! - name: String! - } + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'supplier'], + }); - type Todo @key(fields: "oldTodoKey") { - oldTodoKey: String! - } - `; + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - 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); + type Query { + node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") + } - const nodeQualifier = (node) => - node.name.value !== 'Todo' || - node.directives.filter((directive) => directive.name.value === 'key') - .length > 1; + interface Node @tag(name: "storefront") @tag(name: "supplier") { + id: ID! + } - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: [], - typeExceptions: [], - nodeQualifier, + type Product implements Node @key(fields: "upc") { + id: ID! @tag(name: "storefront") + upc: String! + } + ` + ); }); - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - + it('propogates tags to the id field based on tags of sibling fields across subgraphs', () => { + const productSchema = gql` type Query { - node(id: ID!): Node + user(id: String): User } - interface Node { - id: ID! + 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 Todo implements Node @key(fields: "todoId") { - id: ID! - todoId: Int! + type Brand @key(fields: "brandId") { + brandId: Int! @tag(name: "storefront") @tag(name: "internal") + name: String @tag(name: "storefront") @tag(name: "internal") } - type User implements Node @key(fields: "userId") { - id: ID! + 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") } - ` - ); - }); - it('ignores the existing relay subgraph when generating types', () => { - const userSchema = gql` - type Query { - user(id: String): User - } + 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); - type User @key(fields: "userId") { - userId: String! - name: String! - } - `; - const todoSchema = gql` - type Todo @key(fields: "todoId") { - todoId: Int! - text: String! - complete: Boolean! - } + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'supplier'], + }); - type User @key(fields: "userId", resolvable: false) { - userId: String! - } - `; - const relaySchema = gql` - directive @tag( - name: String! - ) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type Query { - node(id: ID!): Node - } + type Query { + node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") + } - interface Node { - id: ID! - } + interface Node @tag(name: "storefront") @tag(name: "supplier") { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + 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 StorefrontUser implements Node @key(fields: "userId") { + 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") { + id: ID! @tag(name: "internal") + todoId: Int! + } + ` + ); + }); + + it('generates custom scalar definitions w/global tags when they are used on a type definition in the schema', () => { + 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); - type User implements Node @key(fields: "userId") { - id: ID! - userId: String! - } + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + }); - type Todo implements Node @key(fields: "todoId") { - id: ID! - todoId: Int! - } + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - type AnotherType implements Node @key(fields: "someId") { - id: ID! - someId: Int! - } - `; - const subgraphs = new Map(); - subgraphs.set('user-subgraph', userSchema); - subgraphs.set('todo-subgraph', todoSchema); - subgraphs.set('relay-subgraph', relaySchema); + scalar UsedCustomScalar1 - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - }); + scalar UsedCustomScalar2 - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + enum UsedEnum { + VALUE_ONE + VALUE_TWO @inaccessible + VALUE_THREE + } type Query { - node(id: ID!): Node + node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") } - interface Node { + interface Node @tag(name: "internal") @tag(name: "storefront") { id: ID! } - type User implements Node @key(fields: "userId") { + type User implements Node @key(fields: "userId customField1 customField2 customEnum1 customEnum2") { id: ID! userId: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + customEnum1: UsedEnum + customEnum2: [UsedEnum!]! } - type Todo implements Node @key(fields: "todoId") { + type Todo implements Node @key(fields: "todoId customField") { id: ID! todoId: Int! + customField: UsedCustomScalar1 } ` - ); - }); + ); + }); - it('generates custom scalar definitions when they are used on a type definition in the schema', () => { - const userSchema = gql` - scalar UsedCustomScalar1 - scalar UsedCustomScalar2 - scalar UnusedCustomScalar + it('tags are identified from field arguments', () => { + const urlSchema = gql` + type TypeA @key(fields: "selections { selectionId }") { + selections: [TypeB!] @inaccessible + fieldWithArgument(argument: Int @tag(name: "storefront")): Boolean + } - type Query { - user(id: String): User - } + type TypeB @key(fields: "selectionId", resolvable: false) { + selectionId: String! + } + `; - type User @key(fields: "userId customField1 customField2") { - userId: String! - name: String! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! - unusedField: UnusedCustomScalar - } - `; - const todoSchema = gql` - scalar UsedCustomScalar1 + const altSchema = gql` + type TypeB @key(fields: "selectionId") { + selectionId: String! @tag(name: "storefront") + } + `; - 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 subgraphs = new Map(); + subgraphs.set('url-subgraph', urlSchema); + subgraphs.set('alt-subgraph', altSchema); - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - }); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + }); - expect(actual).toEqual( - // prettier-ignore - gql` + expect(actual).toEqual( + // prettier-ignore + gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - scalar UsedCustomScalar1 - - scalar UsedCustomScalar2 - type Query { - node(id: ID!): Node + node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") } - interface Node { + interface Node @tag(name: "internal") @tag(name: "storefront") { id: ID! } - type User implements Node @key(fields: "userId customField1 customField2") { - id: ID! - userId: String! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! + type TypeA implements Node @key(fields: "selections { __typename selectionId }") { + id: ID! @tag(name: "storefront") + selections: [TypeB!] } - type Todo implements Node @key(fields: "todoId customField") { - id: ID! - todoId: Int! - customField: UsedCustomScalar1 + type TypeB implements Node @key(fields: "selectionId") { + id: ID! @tag(name: "storefront") + selectionId: String! } ` - ); + ); + }); }); - describe('when using contacts with @tag', () => { - it('propogates valid tags to all core relay object identification types', () => { + describe('when generating schema for complex keys', () => { + it('uses a custom key sorter to prefer complex keys', () => { const productSchema = gql` type Query { topProducts(first: Int = 5): [Product] } - type Product @key(fields: "upc") { + type Product + @key(fields: "upc sku") + @key(fields: "upc sku brand { brandId store { storeId } }") + @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); @@ -1039,7 +1060,11 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'supplier'], + keySorter: (keys) => { + return keys.sort((a, b) => { + return b.depth - a.depth; + }); + }, }); expect(actual).toEqual( @@ -1048,33 +1073,59 @@ describe('FroidSchema class', () => { 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") + node(id: ID!): Node } - interface Node @tag(name: "storefront") @tag(name: "supplier") { + interface Node { id: ID! } - type Product implements Node @key(fields: "upc") { + type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { id: ID! upc: String! + sku: String! + brand: [Brand!]! + } + + type Brand { + brandId: Int! + store: Store + } + + type Store { + storeId: Int! } ` ); }); - it('uses the first non-id key directive despite contract tags', () => { + it('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: "name") { - upc: String! @inaccessible - name: String @tag(name: "storefront") + 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); @@ -1082,7 +1133,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'supplier'], + keySorter: (keys) => keys, }); expect(actual).toEqual( @@ -1091,71 +1142,66 @@ describe('FroidSchema class', () => { 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") + node(id: ID!): Node } - interface Node @tag(name: "storefront") @tag(name: "supplier") { + interface Node { id: ID! } type Product implements Node @key(fields: "upc") { - id: ID! @tag(name: "storefront") + id: ID! upc: String! } ` ); }); - it('propogates tags to the id field based on tags of sibling fields across subgraphs', () => { + it('uses a custom key sorter to prefer complex keys only when the node is named "Book"', () => { 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") + topProducts(first: Int = 5): [Product] } - type StorefrontUser @key(fields: "userId") { - userId: String! @tag(name: "storefront") @tag(name: "internal") - name: String! @tag(name: "storefront") + 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 InternalUser @key(fields: "userId") { - userId: String! @tag(name: "internal") - name: String! @tag(name: "internal") + type Brand { + brandId: Int! + store: Store } - `; - const todoSchema = gql` - type StorefrontUser @key(fields: "userId") { - userId: String! - todos: [Todo!]! @tag(name: "internal") + type Book + @key(fields: "bookId") + @key(fields: "bookId author { authorId }") { + bookId: String! + author: Author! } - type Todo @key(fields: "todoId") { - todoId: Int! @tag(name: "internal") - assignedTo: InternalUser! @tag(name: "internal") - title: String! @tag(name: "internal") + type Author { + authorId: String! } `; const subgraphs = new Map(); subgraphs.set('product-subgraph', productSchema); - subgraphs.set('todo-subgraph', todoSchema); const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'supplier'], + keySorter: (keys, node) => { + if (node.name.value === 'Book') { + return keys.sort((a, b) => b.depth - a.depth); + } + return keys; + }, }); expect(actual).toEqual( @@ -1164,187 +1210,142 @@ describe('FroidSchema class', () => { 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") + node(id: ID!): Node } - interface Node @tag(name: "storefront") @tag(name: "supplier") { + interface Node { id: ID! } - type Product implements Node @key(fields: "upc") { - 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 StorefrontUser implements Node @key(fields: "userId") { - id: ID! @tag(name: "internal") @tag(name: "storefront") - userId: String! + type Product implements Node @key(fields: "upc sku") { + id: ID! + upc: String! + sku: String! } - type InternalUser implements Node @key(fields: "userId") { - id: ID! @tag(name: "internal") - userId: String! + type Book implements Node @key(fields: "bookId author { __typename authorId }") { + id: ID! + bookId: String! + author: Author! } - type Todo implements Node @key(fields: "todoId") { - id: ID! @tag(name: "internal") - todoId: Int! + type Author { + authorId: String! } ` ); }); - it('generates custom scalar definitions w/global tags when they are used on a type definition in the schema', () => { - const userSchema = gql` - scalar UsedCustomScalar1 - scalar UsedCustomScalar2 - scalar UnusedCustomScalar - - enum UsedEnum { - VALUE_ONE @customDirective - VALUE_TWO @customDirective @inaccessible - VALUE_THREE + it('generates valid schema for entity with nested complex (multi-field) keys', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] } - type Query { - user(id: String): User + 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 User - @key( - fields: "userId customField1 customField2 customEnum1 customEnum2" - ) { - userId: String! - name: String! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! - customEnum1: UsedEnum - customEnum2: [UsedEnum!]! - unusedField: UnusedCustomScalar + type Brand { + brandId: Int! + store: Store } - `; - const todoSchema = gql` - scalar UsedCustomScalar1 - type Todo @key(fields: "todoId customField") { - todoId: Int! - text: String! - complete: Boolean! - customField: UsedCustomScalar1 + type Store { + storeId: Int! } `; const subgraphs = new Map(); - subgraphs.set('user-subgraph', userSchema); - subgraphs.set('todo-subgraph', todoSchema); + subgraphs.set('product-subgraph', productSchema); const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], }); expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - - scalar UsedCustomScalar1 - - scalar UsedCustomScalar2 + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - enum UsedEnum { - VALUE_ONE - VALUE_TWO @inaccessible - VALUE_THREE - } + type Query { + node(id: ID!): Node + } - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } + interface Node { + id: ID! + } - interface Node @tag(name: "internal") @tag(name: "storefront") { - id: ID! - } + type Product implements Node @key(fields: "upc sku brand { __typename brandId store { __typename storeId } }") { + id: ID! + upc: String! + sku: String! + brand: [Brand!]! + } - type User implements Node @key(fields: "userId customField1 customField2 customEnum1 customEnum2") { - id: ID! - userId: String! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! - customEnum1: UsedEnum - customEnum2: [UsedEnum!]! - } + type Brand { + brandId: Int! + store: Store + } - type Todo implements Node @key(fields: "todoId customField") { - id: ID! - todoId: Int! - customField: UsedCustomScalar1 - } - ` + type Store { + storeId: Int! + } + ` ); }); - it('tags are identified 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! + it('generates valid schema for entity with complex (multi-field) keys', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] } - `; - const altSchema = gql` - type TypeB @key(fields: "selectionId") { - selectionId: String! @tag(name: "storefront") + type Product @key(fields: "upc sku") { + upc: String! + sku: String! + name: String + price: Int + weight: Int } `; - const subgraphs = new Map(); - subgraphs.set('url-subgraph', urlSchema); - subgraphs.set('alt-subgraph', altSchema); + subgraphs.set('product-subgraph', productSchema); const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], }); expect(actual).toEqual( // prettier-ignore 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") - } + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) - interface Node @tag(name: "internal") @tag(name: "storefront") { - id: ID! - } + type Query { + node(id: ID!): Node + } - type TypeA implements Node @key(fields: "selections { __typename selectionId }") { - id: ID! @tag(name: "storefront") - selections: [TypeB!] - } + interface Node { + id: ID! + } - type TypeB implements Node @key(fields: "selectionId") { - id: ID! @tag(name: "storefront") - selectionId: String! - } - ` + type Product implements Node @key(fields: "upc sku") { + id: ID! + upc: String! + sku: String! + } + ` ); }); - }); - describe('when generating schema for complex keys', () => { it('finds the complete schema cross-subgraph', () => { const magazineSchema = gql` type Magazine @@ -1449,5 +1450,117 @@ describe('FroidSchema class', () => { ` ); }); + + it('properly identifies shareable fields', () => { + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "internal") @tag(name: "storefront") { + id: ID! + } + + type Book implements Node @key(fields: "author { __typename name authorId }") { + id: ID! + author: Author! + } + + type Author implements Node @key(fields: "authorId") { + id: ID! + authorId: Int! + name: String! @shareable + } + ` + ); + }); + + it('properly identifies external fields', () => { + 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + 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") + } + + interface Node @tag(name: "internal") @tag(name: "storefront") { + id: ID! + } + + type Book implements Node @key(fields: "author { __typename name authorId }") { + id: ID! + author: Author! + } + + type Author implements Node @key(fields: "authorId") { + id: ID! + authorId: Int! + name: String! @external + } + ` + ); + }); }); }); diff --git a/src/schema/constants.ts b/src/schema/constants.ts index 8e97908..2394626 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -5,17 +5,35 @@ export const FED2_DEFAULT_VERSION = 'v2.0'; export const FED2_VERSION_PREFIX = 'v2.'; 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 const CONTRACT_DIRECTIVE_NAME = 'tag'; -export const EXTERNAL_DIRECTIVE_AST = { - kind: Kind.DIRECTIVE, - name: {kind: Kind.NAME, value: EXTERNAL_DIRECTIVE}, -} as ConstDirectiveNode; + +export enum DirectiveName { + Extends = 'extends', + External = 'external', + InterfaceObject = 'interfaceObject', + Key = 'key', + Shareable = 'shareable', + 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 SHAREABLE_DIRECTIVE = DirectiveName.Shareable; +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 SHAREABLE_DIRECTIVE_AST = { + kind: Kind.DIRECTIVE, + name: {kind: Kind.NAME, value: DirectiveName.Shareable}, +} as ConstDirectiveNode; From 2fd9e7e7000b7aa5274a4905acd343a4749420f9 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 30 Oct 2023 12:24:36 -0400 Subject: [PATCH 06/27] feat: update fed @link imports and drop unused code --- src/schema/FroidSchema.ts | 7 +-- src/schema/Key.ts | 55 +++--------------------- src/schema/KeyField.ts | 33 +++----------- src/schema/ObjectType.ts | 11 ----- src/schema/__tests__/FroidSchema.test.ts | 44 +++++++++---------- src/schema/constants.ts | 17 ++++++++ src/schema/createLinkSchemaExtension.ts | 4 +- 7 files changed, 57 insertions(+), 114 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index ec237f9..096fb71 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -19,9 +19,11 @@ import { import {ObjectTypeNode} from './types'; import { CONTRACT_DIRECTIVE_NAME, + DEFAULT_FEDERATION_LINK_IMPORTS, DirectiveName, EXTERNAL_DIRECTIVE_AST, FED2_DEFAULT_VERSION, + FED2_OPT_IN_URL, FED2_VERSION_PREFIX, ID_FIELD_NAME, ID_FIELD_TYPE, @@ -29,7 +31,6 @@ import { } from './constants'; import assert from 'assert'; import {implementsNodeInterface} from './astDefinitions'; -import {FED2_OPT_IN_URL} from './createLinkSchemaExtension'; import {Key} from './Key'; import {KeyField} from './KeyField'; import {ObjectType} from './ObjectType'; @@ -174,7 +175,7 @@ export class FroidSchema { this.froidAst = { kind: Kind.DOCUMENT, definitions: [ - this.createLinkSchemaExtension(['@key', '@tag']), + this.createLinkSchemaExtension(), ...this.createCustomReturnTypes(), this.createQueryDefinition(), this.createNodeInterface(), @@ -407,7 +408,7 @@ export class FroidSchema { * @returns {SchemaExtensionNode} A schema extension node that includes the @link directive. */ private createLinkSchemaExtension( - imports: string[] = ['@key'] + imports: string[] = DEFAULT_FEDERATION_LINK_IMPORTS ): SchemaExtensionNode { if (!imports.length) { throw new Error('At least one import must be provided.'); diff --git a/src/schema/Key.ts b/src/schema/Key.ts index 5af30dd..892b114 100644 --- a/src/schema/Key.ts +++ b/src/schema/Key.ts @@ -20,21 +20,11 @@ import { * Represents an object key directive as a structures object. */ export class Key { - /** - * The name of the object this key is associated with. - */ - public readonly typename: string; /** * The key's fields. */ private _fields: KeyField[] = []; - /** - * Creates a key from the first key directive of an ObjectTypeNode. - * - * @param {ObjectTypeNode} objectType - The object node the key will be generated from - */ - constructor(objectType: ObjectTypeNode); /** * Creates a key from the key fields of an object type. * @@ -52,48 +42,15 @@ export class Key { /** * Creates a key from an object type, key directive AST, or key directive fields string. * - * @param {string|ObjectTypeNode} type - An object type or its name - * @param {string|ConstDirectiveNode|undefined} keyFields - The object's key directive or fields + * @param {string|ObjectTypeNode} typename - An object type or its name + * @param {string|ConstDirectiveNode} keyFields - The object's key directive or fields */ constructor( - type: string | ObjectTypeNode, - keyFields?: string | ConstDirectiveNode + public readonly typename: string, + keyFields: string | ConstDirectiveNode ) { - if (typeof type === 'string') { - assert( - keyFields, - `Cannot create key without both a typename and key fields. Received typename '${type}' and key fields '${keyFields}'` - ); - this.typename = type; - this.parseToFields(keyFields); - return; - } - const objectType = type; - this.typename = objectType.name.value; - this._fields = []; - - if (!objectType.directives) { - // If the object has no directives as all, we can safely skip it - return; - } - - const keyDirective = objectType.directives.find( - (directive) => directive.name.value === DirectiveName.Key - ); - - if (!keyDirective) { - // If the object has no key, we can safely skip it - return; - } - - const fieldsString = Key.getKeyDirectiveFields(keyDirective); - - assert( - fieldsString, - `Encountered an @key directive with an improperly formatted "fields" argument on type "${this.typename}".` - ); - - this.parseToFields(fieldsString); + this.parseToFields(keyFields); + return; } /** diff --git a/src/schema/KeyField.ts b/src/schema/KeyField.ts index 8b0c108..e57b16e 100644 --- a/src/schema/KeyField.ts +++ b/src/schema/KeyField.ts @@ -1,4 +1,4 @@ -import {FieldNode, Kind, SelectionNode} from 'graphql'; +import {FieldNode} from 'graphql'; import {TYPENAME_FIELD_NAME} from './constants'; /** @@ -17,30 +17,23 @@ export class KeyField { /** * Creates a key field. * - * @param {FieldNode|KeyField} field - The field this key field will represent + * @param {FieldNode} field - The field this key field will represent */ - constructor(field: FieldNode | KeyField) { - if (field instanceof KeyField) { - this.name = KeyField.name; - this._selections = field._selections; - return; - } + constructor(field: FieldNode) { this.name = field.name.value; field.selectionSet?.selections?.forEach((selection) => - this.addSelection(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 {SelectionNode} selection - Selection AST + * @param {FieldNode} selection - Selection field AST * @returns {void} */ - public addSelection(selection: SelectionNode): void { - if (selection.kind !== Kind.FIELD) { - return; - } + public addSelection(selection: FieldNode): void { if ( selection.name.value === TYPENAME_FIELD_NAME || this._selections.find((field) => field.name === selection.name.value) @@ -58,9 +51,6 @@ export class KeyField { */ public merge(keyField: KeyField): void { keyField._selections.forEach((mergeSelection) => { - if (mergeSelection.name === TYPENAME_FIELD_NAME) { - return; - } const existingSelection = this._selections.find( (compareSelection) => compareSelection.name === mergeSelection.name ); @@ -81,15 +71,6 @@ export class KeyField { return this._selections; } - /** - * The names of the first level of this key field's child selections. - * - * @returns {string[]} - The child selection names - */ - public get selectionsList(): string[] { - return this._selections.map((selection) => selection.name); - } - /** * Converts this key field and its child selections to a string. * diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index e144e2a..914a239 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -93,17 +93,6 @@ export class ObjectType { ); } - /** - * All the fields of the node that do not appear in keys. - * - * @returns {FieldDefinitionNode[]} The list of non-key fields - */ - public get allNonKeyFields(): FieldDefinitionNode[] { - return this.allFields.filter( - (field) => !this.allKeyFieldsList.includes(field.name.value) - ); - } - /** * The key selected for use in the FROID schema. * diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 7deadc3..f906405 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -138,7 +138,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -177,7 +177,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -235,7 +235,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -341,7 +341,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -401,7 +401,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -459,7 +459,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -521,7 +521,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -607,7 +607,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -670,7 +670,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) scalar UsedCustomScalar1 @@ -726,7 +726,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") @@ -769,7 +769,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") @@ -842,7 +842,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") @@ -932,7 +932,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) scalar UsedCustomScalar1 @@ -1001,7 +1001,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1070,7 +1070,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -1139,7 +1139,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -1207,7 +1207,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -1273,7 +1273,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -1327,7 +1327,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @@ -1410,7 +1410,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1483,7 +1483,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1539,7 +1539,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") diff --git a/src/schema/constants.ts b/src/schema/constants.ts index 2394626..34eea56 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -3,10 +3,20 @@ 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 TYPENAME_FIELD_NAME = '__typename'; +export enum Directive { + Extends = '@extends', + External = '@external', + InterfaceObject = '@interfaceObject', + Key = '@key', + Shareable = '@shareable', + Tag = '@tag', +} + export enum DirectiveName { Extends = 'extends', External = 'external', @@ -37,3 +47,10 @@ export const SHAREABLE_DIRECTIVE_AST = { kind: Kind.DIRECTIVE, name: {kind: Kind.NAME, value: DirectiveName.Shareable}, } as ConstDirectiveNode; + +export const DEFAULT_FEDERATION_LINK_IMPORTS = [ + Directive.Key, + Directive.Tag, + Directive.External, + Directive.Shareable, +]; diff --git a/src/schema/createLinkSchemaExtension.ts b/src/schema/createLinkSchemaExtension.ts index 7b92840..b92ea26 100644 --- a/src/schema/createLinkSchemaExtension.ts +++ b/src/schema/createLinkSchemaExtension.ts @@ -1,7 +1,5 @@ import {ConstArgumentNode, Kind, SchemaExtensionNode} from 'graphql'; -import {FED2_DEFAULT_VERSION} from './constants'; - -export const FED2_OPT_IN_URL = 'https://specs.apollo.dev/federation/'; +import {FED2_DEFAULT_VERSION, FED2_OPT_IN_URL} from './constants'; export const createLinkSchemaExtension = ( imports: string[] = ['@key'], From 6ece7d69a688526834ba88b68fa3c8dd1ed48d16 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 30 Oct 2023 17:07:05 -0400 Subject: [PATCH 07/27] feat: handle infinite loops --- src/schema/ObjectType.ts | 61 ++++++++++++++++++++---- src/schema/__tests__/FroidSchema.test.ts | 50 +++++++++++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 914a239..a37a135 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -5,10 +5,13 @@ import {ObjectTypeNode} from './types'; import {FroidSchema, KeySorter} from './FroidSchema'; import {KeyField} from './KeyField'; +const FINAL_KEY_MAX_DEPTH = 200; + /** * Collates information about an object type definition node. */ export class ObjectType { + public readonly typename: string; private _externallySelectedFields: string[] = []; /** @@ -25,7 +28,9 @@ export class ObjectType { private readonly objectTypes: ObjectTypeDefinitionNode[], private readonly extensionAndDefinitionNodes: ObjectTypeNode[], private readonly keySorter: KeySorter - ) {} + ) { + this.typename = this.node.name.value; + } /** * All occurrences of the node across all subgraph schemas. @@ -222,31 +227,69 @@ export class ObjectType { /** * The node's key after all key fields used by other entities are added. * - * @todo handle key recursion... + * @todo handle key recursion: + * - Have a max depth and kill recursion if it reaches the max depth (100 levels? 1000 levels?) + * - If the parent (Book) is the same type as the child's (Author) grandchild (Book), stop recursion * @returns {Key|undefined} The final key. Undefined if the node is not an entity. */ public get finalKey(): Key | undefined { + return this.getFinalKey(); + } + + /** + * + * @param depth + * @param ancestors + * @returns + */ + 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 mergedKey = new Key( this.node.name.value, this.selectedKey.toString() ); - const keyFromSelections = new Key( - this.node.name.value, - [...this.selectedKeyFields.map((field) => field.name.value)].join(' ') - ); - mergedKey.merge(keyFromSelections); + const selectedKeyFields = [ + ...this.selectedKeyFields.map((field) => field.name.value), + ].join(' '); + + if (selectedKeyFields) { + const keyFromSelections = new Key( + this.node.name.value, + 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]; - if (!dependency.finalKey) { + const dependencyFinalKey = dependency.getFinalKey(depth + 1, [ + ...ancestors, + this.typename, + ]); + if (!dependencyFinalKey) { return; } const keyToMerge = new Key( this.node.name.value, - `${dependentField} { ${dependency.finalKey.toString()} }` + `${dependentField} { ${dependencyFinalKey.toString()} }` ); mergedKey.merge(keyToMerge); } diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index f906405..e23a67a 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -1562,5 +1562,55 @@ describe('FroidSchema class', () => { ` ); }); + + it.only('Stops 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'], + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + + type Query { + node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") + } + + interface Node @tag(name: "internal") @tag(name: "storefront") { + id: ID! + } + + type Book implements Node @key(fields: "author { __typename name book { __typename title } }") { + id: ID! + author: Author! + title: String! @external + } + + type Author implements Node @key(fields: "book { __typename title author { __typename name } }") { + id: ID! + book: Book! + name: String! @external + } + ` + ); + }); }); }); From 28f2dc3d0ee01f21830a01be087b8860e2d67dac Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 30 Oct 2023 17:27:27 -0400 Subject: [PATCH 08/27] chore: ignore .DS_Store files --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 2fdd8c88d4e00f37dc0d6f1b7810dc78dc8550e4 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 30 Oct 2023 17:27:45 -0400 Subject: [PATCH 09/27] test: improve code coverage --- src/schema/__tests__/FroidSchema.test.ts | 79 ++++++++++++++++++- src/schema/__tests__/Key.test.ts | 17 ++++ .../createLinkSchemaExtension.test.ts | 40 ++++++++++ src/schema/createLinkSchemaExtension.ts | 8 +- 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 src/schema/__tests__/Key.test.ts create mode 100644 src/schema/__tests__/createLinkSchemaExtension.test.ts diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index e23a67a..7373c61 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -1,6 +1,6 @@ import {stripIndent as gql} from 'common-tags'; import {FroidSchema} from '../FroidSchema'; -import {DefinitionNode} from 'graphql'; +import {DefinitionNode, Kind} from 'graphql'; import {ObjectTypeNode} from '../types'; import {Key} from '../Key'; @@ -109,6 +109,59 @@ describe('FroidSchema class', () => { ); }); + 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', subgraphs, {}); + + expect(froid.toAst().kind).toEqual(Kind.DOCUMENT); + }); + + it('omits interface object', () => { + 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', + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "upc") { + id: ID! + upc: String! + } + ` + ); + }); + it('ignores @key(fields: "id") directives', () => { const productSchema = gql` type Query { @@ -1563,7 +1616,7 @@ describe('FroidSchema class', () => { ); }); - it.only('Stops recursion when an already-visited ancestor is encountered', () => { + it('Stops recursion when an already-visited ancestor is encountered', () => { const bookSchema = gql` type Book @key(fields: "author { name }") { author: Author! @@ -1613,4 +1666,26 @@ describe('FroidSchema class', () => { ); }); }); + + 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', 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/createLinkSchemaExtension.ts b/src/schema/createLinkSchemaExtension.ts index b92ea26..eaa8bc1 100644 --- a/src/schema/createLinkSchemaExtension.ts +++ b/src/schema/createLinkSchemaExtension.ts @@ -1,8 +1,12 @@ import {ConstArgumentNode, Kind, SchemaExtensionNode} from 'graphql'; -import {FED2_DEFAULT_VERSION, FED2_OPT_IN_URL} from './constants'; +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) { From ee9dd39f566acc124c58246ef1109cbed439693a Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 1 Nov 2023 09:16:55 -0400 Subject: [PATCH 10/27] refactor: apply PR suggestions --- src/schema/FroidSchema.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 096fb71..977700d 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -364,7 +364,8 @@ export class FroidSchema { } /** - * Returns all non-extended types with explicit ownership to a single subgraph + * 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 @@ -391,14 +392,12 @@ export class FroidSchema { .filter(Boolean) .sort() as string[]; - const tagDirectives: ConstDirectiveNode[] = []; - const uniqueTagDirectivesNames = [...new Set(tagDirectiveNames || [])]; - - uniqueTagDirectivesNames.forEach((tagName) => { - tagDirectives.push(FroidSchema.createTagDirective(tagName)); - }); - - return tagDirectives; + const uniqueTagDirectivesNames: string[] = [ + ...new Set(tagDirectiveNames || []), + ]; + return uniqueTagDirectivesNames.map((tagName) => + FroidSchema.createTagDirective(tagName) + ); } /** From 2cf85e13953f0bffa06038ba04dc8b37fa4a5604 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 1 Nov 2023 09:27:41 -0400 Subject: [PATCH 11/27] feat: treat shareable non-key fields as external --- src/schema/FroidSchema.ts | 42 +----------------- src/schema/__tests__/FroidSchema.test.ts | 54 ++++++++++++------------ src/schema/constants.ts | 9 ---- 3 files changed, 28 insertions(+), 77 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 977700d..1059e5b 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -27,7 +27,6 @@ import { FED2_VERSION_PREFIX, ID_FIELD_NAME, ID_FIELD_TYPE, - SHAREABLE_DIRECTIVE_AST, } from './constants'; import assert from 'assert'; import {implementsNodeInterface} from './astDefinitions'; @@ -194,9 +193,6 @@ export class FroidSchema { ({node, finalKey, selectedKeyFields, selectedNonKeyFields}) => { let froidFields: FieldDefinitionNode[] = []; let externalFieldDirectives: ConstDirectiveNode[] = []; - const shareableFieldDirectives: ConstDirectiveNode[] = [ - SHAREABLE_DIRECTIVE_AST, - ]; let froidInterfaces: NamedTypeNode[] = []; if (FroidSchema.isEntity(node)) { froidFields = [ @@ -210,10 +206,7 @@ export class FroidSchema { ...selectedKeyFields.map((field) => ({...field, directives: []})), ...selectedNonKeyFields.map((field) => ({ ...field, - directives: FroidSchema.isShareable(field) - ? // @todo test the shareable branch of this logic - shareableFieldDirectives - : externalFieldDirectives, + directives: externalFieldDirectives, })), ]; const finalKeyDirective = finalKey?.toDirective(); @@ -834,39 +827,6 @@ export class FroidSchema { ); } - /** - * Check whether or not a list of nodes contains a shareable node. - * - * @param {(ObjectTypeNode | FieldDefinitionNode)[]} nodes - The nodes to check - * @returns {boolean} Whether or not any nodes are shareable - */ - private static isShareable(nodes: (ObjectTypeNode | FieldDefinitionNode)[]); - /** - * Check whether or not a node is shareable. - * - * @param {ObjectTypeNode | FieldDefinitionNode} node - A node to check - * @returns {boolean} Whether or not the node is shareable - */ - private static isShareable(node: ObjectTypeNode | FieldDefinitionNode); - /** - * Check whether or not one of more nodes is shareable. - * - * @param {(ObjectTypeNode | FieldDefinitionNode) | (ObjectTypeNode | FieldDefinitionNode)[]} node - One or more nodes to collectively check - * @returns {boolean} Whether or not any nodes are shareable - */ - private static isShareable( - node: - | (ObjectTypeNode | FieldDefinitionNode) - | (ObjectTypeNode | FieldDefinitionNode)[] - ): boolean { - const nodesToCheck = Array.isArray(node) ? node : [node]; - return nodesToCheck.some((node) => - node?.directives?.some( - (directive) => directive.name.value === DirectiveName.Shareable - ) - ); - } - /** * Parse a schema string to AST. * diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 7373c61..bc4f67b 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -144,7 +144,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -191,7 +191,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -230,7 +230,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -288,7 +288,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -394,7 +394,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -454,7 +454,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -512,7 +512,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -574,7 +574,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -660,7 +660,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -723,7 +723,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) scalar UsedCustomScalar1 @@ -779,7 +779,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") @@ -822,7 +822,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") @@ -895,7 +895,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "storefront") @tag(name: "supplier") @@ -985,7 +985,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) scalar UsedCustomScalar1 @@ -1054,7 +1054,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1123,7 +1123,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -1192,7 +1192,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -1260,7 +1260,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -1326,7 +1326,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -1380,7 +1380,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @@ -1463,7 +1463,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1504,7 +1504,7 @@ describe('FroidSchema class', () => { ); }); - it('properly identifies shareable fields', () => { + 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! @@ -1536,7 +1536,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1554,13 +1554,13 @@ describe('FroidSchema class', () => { type Author implements Node @key(fields: "authorId") { id: ID! authorId: Int! - name: String! @shareable + name: String! @external } ` ); }); - it('properly identifies external fields', () => { + 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! @@ -1592,7 +1592,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") @@ -1641,7 +1641,7 @@ describe('FroidSchema class', () => { expect(actual).toEqual( // prettier-ignore gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external", "@shareable"]) + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) type Query { node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") diff --git a/src/schema/constants.ts b/src/schema/constants.ts index 34eea56..3ad67cd 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -13,7 +13,6 @@ export enum Directive { External = '@external', InterfaceObject = '@interfaceObject', Key = '@key', - Shareable = '@shareable', Tag = '@tag', } @@ -22,7 +21,6 @@ export enum DirectiveName { External = 'external', InterfaceObject = 'interfaceObject', Key = 'key', - Shareable = 'shareable', Tag = 'tag', } @@ -35,7 +33,6 @@ 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 SHAREABLE_DIRECTIVE = DirectiveName.Shareable; export const INTERFACE_OBJECT_DIRECTIVE = DirectiveName.InterfaceObject; export const EXTERNAL_DIRECTIVE_AST = { @@ -43,14 +40,8 @@ export const EXTERNAL_DIRECTIVE_AST = { name: {kind: Kind.NAME, value: DirectiveName.External}, } as ConstDirectiveNode; -export const SHAREABLE_DIRECTIVE_AST = { - kind: Kind.DIRECTIVE, - name: {kind: Kind.NAME, value: DirectiveName.Shareable}, -} as ConstDirectiveNode; - export const DEFAULT_FEDERATION_LINK_IMPORTS = [ Directive.Key, Directive.Tag, Directive.External, - Directive.Shareable, ]; From cd722b12cca13bec8501cce29cb19a72b9f3ad2d Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 1 Nov 2023 09:31:09 -0400 Subject: [PATCH 12/27] refactor: reduce key recursion limit and add doc block info --- src/schema/ObjectType.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index a37a135..6d09cf2 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -5,7 +5,7 @@ import {ObjectTypeNode} from './types'; import {FroidSchema, KeySorter} from './FroidSchema'; import {KeyField} from './KeyField'; -const FINAL_KEY_MAX_DEPTH = 200; +const FINAL_KEY_MAX_DEPTH = 100; /** * Collates information about an object type definition node. @@ -227,9 +227,6 @@ export class ObjectType { /** * The node's key after all key fields used by other entities are added. * - * @todo handle key recursion: - * - Have a max depth and kill recursion if it reaches the max depth (100 levels? 1000 levels?) - * - If the parent (Book) is the same type as the child's (Author) grandchild (Book), stop recursion * @returns {Key|undefined} The final key. Undefined if the node is not an entity. */ public get finalKey(): Key | undefined { @@ -237,10 +234,11 @@ export class ObjectType { } /** + * Generated the final key for the node based on all descendant types and their keys (if they have keys). * - * @param depth - * @param ancestors - * @returns + * @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) { From add420411af0455f605fea752d13ae8f9b1de6eb Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 1 Nov 2023 09:32:13 -0400 Subject: [PATCH 13/27] test: update test description --- src/schema/__tests__/FroidSchema.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index bc4f67b..98543b7 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -248,8 +248,7 @@ describe('FroidSchema class', () => { ); }); - // @todo FIX THE NAME OF THIS TEST - it('defaults to generating valid schema using the first non-nested complex (multi-field) keys', () => { + it('defaults to generating valid schema using the first key found regardless of complexity', () => { const productSchema = gql` type Query { topProducts(first: Int = 5): [Product] From 3e9fc56933869438c0935a34208eb3a141ba77f7 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Thu, 2 Nov 2023 17:22:23 -0400 Subject: [PATCH 14/27] feat: export FroidSchema class --- src/index.ts | 1 + 1 file changed, 1 insertion(+) 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'; From 381b73a4b983f24a3a3cf3a84e383fb57b7276d1 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Thu, 2 Nov 2023 17:22:44 -0400 Subject: [PATCH 15/27] feat: add schema descriptions, sorting, and field qualification --- src/schema/FroidSchema.ts | 92 +- src/schema/Key.ts | 94 +- src/schema/ObjectType.ts | 50 +- src/schema/__tests__/FroidSchema.test.ts | 930 +++++++++++++----- .../generateFroidSchema.fed-v1.test.ts | 516 ++++++---- .../generateFroidSchema.fed-v2.test.ts | 461 ++++++--- src/schema/__tests__/sortDocumentAst.test.ts | 87 ++ src/schema/createIdField.ts | 4 + src/schema/createNodeInterface.ts | 4 + src/schema/createQueryDefinition.ts | 8 + src/schema/generateFroidSchema.ts | 5 +- src/schema/sortDocumentAst.ts | 263 +++++ 12 files changed, 1917 insertions(+), 597 deletions(-) create mode 100644 src/schema/__tests__/sortDocumentAst.test.ts create mode 100644 src/schema/sortDocumentAst.ts diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 1059e5b..03101e9 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -1,4 +1,5 @@ import { + ASTNode, ConstArgumentNode, ConstDirectiveNode, DefinitionNode, @@ -22,7 +23,6 @@ import { DEFAULT_FEDERATION_LINK_IMPORTS, DirectiveName, EXTERNAL_DIRECTIVE_AST, - FED2_DEFAULT_VERSION, FED2_OPT_IN_URL, FED2_VERSION_PREFIX, ID_FIELD_NAME, @@ -33,6 +33,7 @@ import {implementsNodeInterface} from './astDefinitions'; import {Key} from './Key'; import {KeyField} from './KeyField'; import {ObjectType} from './ObjectType'; +import {sortDocumentAst} from './sortDocumentAst'; type SupportedFroidReturnTypes = | ScalarTypeDefinitionNode @@ -40,7 +41,7 @@ type SupportedFroidReturnTypes = export type KeySorter = (keys: Key[], node: ObjectTypeNode) => Key[]; export type NodeQualifier = ( - node: DefinitionNode, + node: ASTNode, objectTypes: ObjectTypeNode[] ) => boolean; @@ -121,15 +122,17 @@ export class FroidSchema { * 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 = options?.federationVersion ?? FED2_DEFAULT_VERSION; + this.federationVersion = federationVersion; assert( this.federationVersion.indexOf(FED2_VERSION_PREFIX) > -1, @@ -171,7 +174,7 @@ export class FroidSchema { this.generateFroidDependencies(); // build schema - this.froidAst = { + this.froidAst = sortDocumentAst({ kind: Kind.DOCUMENT, definitions: [ this.createLinkSchemaExtension(), @@ -180,7 +183,7 @@ export class FroidSchema { this.createNodeInterface(), ...this.createObjectTypesAST(), ], - } as DocumentNode; + } as DocumentNode); } /** @@ -203,15 +206,21 @@ export class FroidSchema { } const fields = [ ...froidFields, - ...selectedKeyFields.map((field) => ({...field, directives: []})), + ...selectedKeyFields.map((field) => ({ + ...field, + description: undefined, + directives: [], + })), ...selectedNonKeyFields.map((field) => ({ ...field, + description: undefined, directives: externalFieldDirectives, })), ]; const finalKeyDirective = finalKey?.toDirective(); return { ...node, + description: undefined, interfaces: froidInterfaces, directives: [...(finalKeyDirective ? [finalKeyDirective] : [])], fields, @@ -273,7 +282,8 @@ export class FroidSchema { this.froidObjectTypes, this.objectTypes, this.extensionAndDefinitionNodes, - this.keySorter + this.keySorter, + this.nodeQualifier ); this.froidObjectTypes[node.name.value] = nodeInfo; @@ -491,34 +501,37 @@ export class FroidSchema { this.filteredDefinitionNodes.filter((definitionNode) => typeDefinitionKinds.includes(definitionNode.kind) ) as SupportedFroidReturnTypes[] - ).filter((nonNativeScalarType) => { + ).forEach((nonNativeScalarType) => { const returnTypeName = nonNativeScalarType.name.value; - // Get only types that are returned in froid schema if ( - nonNativeScalarDefinitionNames.has(returnTypeName) && - !nonNativeScalarFieldTypes.has(returnTypeName) + !nonNativeScalarDefinitionNames.has(returnTypeName) || + nonNativeScalarFieldTypes.has(returnTypeName) ) { - 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); - } else if (nonNativeScalarType.kind === Kind.SCALAR_TYPE_DEFINITION) { - nonNativeScalarFieldTypes.set(returnTypeName, { - ...nonNativeScalarType, - description: undefined, - directives: [], - } as ScalarTypeDefinitionNode); - } + // 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()]; @@ -544,6 +557,10 @@ export class FroidSchema { fields: [ { kind: Kind.FIELD_DEFINITION, + description: { + kind: Kind.STRING, + value: 'Fetches an entity by its globally unique identifier.', + }, name: { kind: Kind.NAME, value: 'node', @@ -551,6 +568,10 @@ export class FroidSchema { arguments: [ { kind: Kind.INPUT_VALUE_DEFINITION, + description: { + kind: Kind.STRING, + value: 'A globally unique entity identifier.', + }, name: { kind: Kind.NAME, value: ID_FIELD_NAME, @@ -592,6 +613,11 @@ export class FroidSchema { 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', @@ -652,6 +678,10 @@ export class FroidSchema { ): 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/Key.ts b/src/schema/Key.ts index 892b114..5590761 100644 --- a/src/schema/Key.ts +++ b/src/schema/Key.ts @@ -1,11 +1,14 @@ import { ConstDirectiveNode, DocumentNode, + FieldNode, Kind, OperationDefinitionNode, SelectionNode, + SelectionSetNode, StringValueNode, parse, + print, } from 'graphql'; import {KeyField} from './KeyField'; import {ObjectTypeNode} from './types'; @@ -16,6 +19,9 @@ import { 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. */ @@ -167,7 +173,9 @@ export class Key { * @returns {string} The fields string */ public toString(): string { - return this._fields.map((field) => field.toString()).join(' '); + return Key.getSortedSelectionSetFields( + this._fields.map((field) => field.toString()).join(' ') + ); } /** @@ -221,4 +229,88 @@ export class Key { )?.value as StringValueNode ).value; } + + /** + * Sorts the selection set fields. + * + * @param {string} fields - The selection set fields. + * @returns {string} The sorted selection set fields. + */ + protected 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/ObjectType.ts b/src/schema/ObjectType.ts index 6d09cf2..5ba622c 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -2,7 +2,7 @@ import {FieldDefinitionNode, ObjectTypeDefinitionNode} from 'graphql'; import {Key} from './Key'; import {DirectiveName} from './constants'; import {ObjectTypeNode} from './types'; -import {FroidSchema, KeySorter} from './FroidSchema'; +import {FroidSchema, KeySorter, NodeQualifier} from './FroidSchema'; import {KeyField} from './KeyField'; const FINAL_KEY_MAX_DEPTH = 100; @@ -21,13 +21,15 @@ export class ObjectType { * @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 keySorter: KeySorter, + private readonly nodeQualifier: NodeQualifier ) { this.typename = this.node.name.value; } @@ -63,19 +65,45 @@ export class ObjectType { * @returns {FieldDefinitionNode[]} The list of fields */ public get allFields(): FieldDefinitionNode[] { - const fields: FieldDefinitionNode[] = []; + const fields: Record = {}; this.occurrences.forEach((occurrence) => occurrence?.fields?.forEach((field) => { - if ( - fields.every( - (compareField) => compareField.name.value !== field.name.value - ) - ) { - fields.push(field); - } + fields[field.name.value] = null; + this.addQualifiedField(field, fields); }) ); - return 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.values(fields).filter(Boolean) as FieldDefinitionNode[]; + } + + private addQualifiedField( + field: FieldDefinitionNode, + fields: Record, + applyNodeQualifier = true + ): FieldDefinitionNode | undefined { + 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; } /** diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 98543b7..c96347b 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -1,8 +1,7 @@ import {stripIndent as gql} from 'common-tags'; -import {FroidSchema} from '../FroidSchema'; -import {DefinitionNode, Kind} from 'graphql'; -import {ObjectTypeNode} from '../types'; -import {Key} from '../Key'; +import {FroidSchema, KeySorter, NodeQualifier} from '../FroidSchema'; +import {Kind} from 'graphql'; +import {FED2_DEFAULT_VERSION} from '../constants'; function generateSchema({ subgraphs, @@ -17,26 +16,27 @@ function generateSchema({ froidSubgraphName: string; contractTags?: string[]; typeExceptions?: string[]; - federationVersion?: string; - nodeQualifier?: ( - node: DefinitionNode, - objectTypes: ObjectTypeNode[] - ) => boolean; - keySorter?: (keys: Key[], node: ObjectTypeNode) => Key[]; + federationVersion: string; + nodeQualifier?: NodeQualifier; + keySorter?: KeySorter; }) { - const froidSchema = new FroidSchema(froidSubgraphName, subgraphs, { - contractTags, - typeExceptions, - nodeQualifier, + const froidSchema = new FroidSchema( + froidSubgraphName, federationVersion, - keySorter, - }); + subgraphs, + { + contractTags, + typeExceptions, + nodeQualifier, + keySorter, + } + ); return froidSchema.toString(); } describe('FroidSchema class', () => { - it('defaults the federation version to 2.0', () => { + it('requires a federation version', () => { const productSchema = gql` type Product @key(fields: "upc") { upc: String! @@ -48,17 +48,23 @@ describe('FroidSchema class', () => { const subgraphs = new Map(); subgraphs.set('product-subgraph', productSchema); - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - }); + let errorMessage = ''; + try { + generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: 'v3.1', + }); + } catch (err) { + errorMessage = err.message; + } - expect(actual).toMatch( - 'extend schema @link(url: "https://specs.apollo.dev/federation/v2.0"' + expect(errorMessage).toMatch( + `Federation version must be a valid 'v2.x' version` ); }); - it('honors a custom 2.x federation version', () => { + it('honors a 2.x federation version', () => { const productSchema = gql` type Product @key(fields: "upc") { upc: String! @@ -118,7 +124,12 @@ describe('FroidSchema class', () => { const subgraphs = new Map(); subgraphs.set('product-subgraph', productSchema); - const froid = new FroidSchema('relay-subgraph', subgraphs, {}); + const froid = new FroidSchema( + 'relay-subgraph', + FED2_DEFAULT_VERSION, + subgraphs, + {} + ); expect(froid.toAst().kind).toEqual(Kind.DOCUMENT); }); @@ -139,6 +150,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -146,18 +158,25 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 + } ` ); }); @@ -186,6 +205,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -193,18 +213,25 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 + } ` ); }); @@ -225,6 +252,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -232,18 +260,25 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 + } ` ); }); @@ -282,6 +317,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -289,24 +325,31 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 { __typename brandId store { __typename storeId } }") { + type Product implements Node @key(fields: "brand { __typename brandId store { __typename 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 { @@ -388,6 +431,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -395,23 +439,31 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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! + } ` ); }); @@ -437,7 +489,7 @@ describe('FroidSchema class', () => { `; const brandSchema = gql` - type Brand @key(fields: "brandId") { + type Brand @key(fields: "brandId") @key(fields: "alternateBrandId") { brandId: Int! } `; @@ -448,6 +500,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -455,24 +508,32 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 { __typename 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 { __typename 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 } ` ); @@ -506,6 +567,7 @@ describe('FroidSchema class', () => { froidSubgraphName: 'relay-subgraph', contractTags: [], typeExceptions: ['Todo'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -513,15 +575,22 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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! } @@ -568,6 +637,7 @@ describe('FroidSchema class', () => { contractTags: [], typeExceptions: [], nodeQualifier, + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -575,20 +645,28 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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! } @@ -618,32 +696,42 @@ describe('FroidSchema class', () => { 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 FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - - type Query { - node(id: ID!): Node - } + ) 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 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 AnotherType implements Node @key(fields: "someId") { + type User implements Node @key(fields: "userId") { + "The globally unique identifier." id: ID! - someId: Int! + userId: String! } `; const subgraphs = new Map(); @@ -654,6 +742,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -661,23 +750,31 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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! + } ` ); }); @@ -717,6 +814,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -724,35 +822,147 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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!]! + userId: String! + } + ` + ); + }); + + it('only includes descriptions for schema 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! } - type Todo implements Node @key(fields: "todoId customField") { + "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! - todoId: Int! 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! } ` ); }); - describe('when using contacts with @tag', () => { + describe('when using contracts with @tag', () => { it('propogates valid tags to all core relay object identification types', () => { const productSchema = gql` type Query { @@ -773,6 +983,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -780,23 +991,30 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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") + } ` ); }); - it('uses the first non-id key directive despite contract tags', () => { + it('uses the first entity key, regardless of tagging or accessibility, and accurately tags the id field', () => { const productSchema = gql` type Query { topProducts(first: Int = 5): [Product] @@ -816,6 +1034,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -823,23 +1042,30 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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") + } ` ); }); - it('propogates tags to the id field based on tags of sibling fields across subgraphs', () => { + it('propagates tags to the id field based on tags of sibling fields across subgraphs', () => { const productSchema = gql` type Query { user(id: String): User @@ -889,6 +1115,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -896,35 +1123,46 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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! } @@ -979,6 +1217,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -986,37 +1225,45 @@ describe('FroidSchema class', () => { 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_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! } ` ); @@ -1048,6 +1295,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1055,20 +1303,28 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 { __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! } @@ -1078,7 +1334,7 @@ describe('FroidSchema class', () => { }); describe('when generating schema for complex keys', () => { - it('uses a custom key sorter to prefer complex keys', () => { + it('uses a custom key sorter to prefer the first complex key', () => { const productSchema = gql` type Query { topProducts(first: Int = 5): [Product] @@ -1086,9 +1342,9 @@ describe('FroidSchema class', () => { type Product @key(fields: "upc sku") - @key(fields: "upc sku brand { brandId store { storeId } }") - @key(fields: "upc") - @key(fields: "sku brand { brandId store { storeId } }") { + @key(fields: "brand { brandId store { storeId } }") + @key(fields: "price") + @key(fields: "brand { name }") { upc: String! sku: String! name: String @@ -1100,6 +1356,7 @@ describe('FroidSchema class', () => { type Brand { brandId: Int! store: Store + name: String! } type Store { @@ -1117,6 +1374,7 @@ describe('FroidSchema class', () => { return b.depth - a.depth; }); }, + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1124,24 +1382,29 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 { __typename brandId store { __typename storeId } }") { + type Product implements Node @key(fields: "brand { __typename brandId store { __typename storeId } }") { + "The globally unique identifier." id: ID! - upc: String! - sku: String! brand: [Brand!]! } - 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 { @@ -1186,6 +1449,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', keySorter: (keys) => keys, + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1193,18 +1457,25 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 + } ` ); }); @@ -1254,6 +1525,7 @@ describe('FroidSchema class', () => { } return keys; }, + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1261,28 +1533,36 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Query { - node(id: ID!): Node + type Author { + authorId: String! } - interface Node { + type Book implements Node @key(fields: "author { __typename 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 { __typename 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 } ` ); @@ -1320,6 +1600,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1327,24 +1608,31 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 { __typename brandId store { __typename storeId } }") { + type Product implements Node @key(fields: "brand { __typename brandId store { __typename 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 { @@ -1374,6 +1662,7 @@ describe('FroidSchema class', () => { const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1381,18 +1670,25 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 } ` ); @@ -1457,6 +1753,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1464,40 +1761,49 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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! @external + authorId: Int! + fullName: String! @external } - type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country } }") { + type Book implements Node @key(fields: "author { __typename address { __typename postalCode } authorId fullName } bookId") { + "The globally unique identifier." id: ID! - magazineId: String! - publisher: Publisher! + author: Author! + bookId: String! } - type Book implements Node @key(fields: "bookId author { __typename fullName address { __typename postalCode } authorId }") { + type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country } }") { + "The globally unique identifier." id: ID! - bookId: String! - author: Author! + magazineId: String! + publisher: Publisher! } - type Author implements Node @key(fields: "authorId") { + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." id: ID! - authorId: Int! - fullName: String! @external - address: Address! @external } type Publisher { address: Address! } - type Address { - country: String! - postalCode: String! + 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") } ` ); @@ -1530,6 +1836,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1537,23 +1844,31 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - - interface Node @tag(name: "internal") @tag(name: "storefront") { + 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 name authorId }") { + type Book implements Node @key(fields: "author { __typename authorId name }") { + "The globally unique identifier." id: ID! author: Author! } - type Author implements Node @key(fields: "authorId") { + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." id: ID! - authorId: Int! - name: String! @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") } ` ); @@ -1586,6 +1901,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1593,28 +1909,178 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - - interface Node @tag(name: "internal") @tag(name: "storefront") { + 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 name authorId }") { + type Book implements Node @key(fields: "author { __typename authorId name }") { + "The globally unique identifier." id: ID! author: Author! } - type Author implements Node @key(fields: "authorId") { + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." id: ID! - authorId: Int! - name: String! @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") } ` ); }); + 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 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('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 recursion when an already-visited ancestor is encountered', () => { const bookSchema = gql` type Book @key(fields: "author { name }") { @@ -1635,6 +2101,7 @@ describe('FroidSchema class', () => { subgraphs, froidSubgraphName: 'relay-subgraph', contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); expect(actual).toEqual( @@ -1642,24 +2109,32 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - - interface Node @tag(name: "internal") @tag(name: "storefront") { + 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 name book { __typename title } }") { + type Book implements Node @key(fields: "author { __typename book { __typename title } name }") { + "The globally unique identifier." id: ID! author: Author! title: String! @external } - type Author implements Node @key(fields: "book { __typename title author { __typename name } }") { + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." id: ID! - book: Book! - name: String! @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") } ` ); @@ -1678,7 +2153,12 @@ describe('FroidSchema class', () => { subgraphs.set('product-subgraph', productSchema); try { - const froid = new FroidSchema('relay-subgraph', subgraphs, {}); + const froid = new FroidSchema( + 'relay-subgraph', + FED2_DEFAULT_VERSION, + subgraphs, + {} + ); // @ts-ignore froid.createLinkSchemaExtension([]); } catch (error) { diff --git a/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts b/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts index 42b9373..c66dacc 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") { + "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") { + "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 } }") { + "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 } }") { + "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: "bookId author { authorId }") { + "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") { + "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 - } - - interface Node { + extend type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." id: ID! + brandId: Int! @external } - extend type Brand implements Node @key(fields: "brandId") { + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." id: ID! - brandId: Int! @external } extend type Product implements Node @key(fields: "upc sku brand { brandId }") { + "The globally unique identifier." id: ID! - upc: String! @external - sku: String! @external brand: [Brand!]! @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 ` ); }); @@ -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: "todoId customField") { + "The globally unique identifier." id: ID! + customField: UsedCustomScalar1 @external + todoId: Int! @external } + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + extend type User implements Node @key(fields: "userId customField1 customField2") { + "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: "todoId customField") { + "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") { - id: ID! - } - extend type User implements Node @key(fields: "userId customField1 customField2 customEnum") { + "The globally unique identifier." 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: "bookId author { fullName address { postalCode } }") { + "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..636a670 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") { + "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") { + "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 } }") { + "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: "bookId author { authorId }") { + "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: "upc sku") { + "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 } }") { + "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 - } - - interface Node { + type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." id: ID! + brandId: Int! } - type Brand implements Node @key(fields: "brandId") { + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." id: ID! - brandId: Int! } type Product implements Node @key(fields: "upc sku brand { brandId }") { + "The globally unique identifier." id: ID! - upc: String! - sku: String! 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 } ` ); @@ -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: "todoId customField") { + "The globally unique identifier." id: ID! + customField: UsedCustomScalar1 + todoId: Int! } + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + type User implements Node @key(fields: "userId customField1 customField2") { + "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: "todoId customField") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! + } + scalar UsedCustomScalar1 scalar UsedCustomScalar2 enum UsedEnum { VALUE_ONE - VALUE_TWO @inaccessible VALUE_THREE - } - - type Query { - node(id: ID!): Node @tag(name: "internal") @tag(name: "storefront") - } - - interface Node @tag(name: "internal") @tag(name: "storefront") { - id: ID! + VALUE_TWO @inaccessible } type User implements Node @key(fields: "userId customField1 customField2 customEnum1 customEnum2") { + "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: "bookId author { fullName address { postalCode } }") { + "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..48342ef --- /dev/null +++ b/src/schema/__tests__/sortDocumentAst.test.ts @@ -0,0 +1,87 @@ +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 { + 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 { + 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! + } + ` + ); + }); +}); 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/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..ec0dab9 --- /dev/null +++ b/src/schema/sortDocumentAst.ts @@ -0,0 +1,263 @@ +import { + ASTNode, + ArgumentNode, + DefinitionNode, + DirectiveDefinitionNode, + DirectiveNode, + DocumentNode, + EnumTypeDefinitionNode, + EnumTypeExtensionNode, + EnumValueDefinitionNode, + FieldDefinitionNode, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, + InputValueDefinitionNode, + InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + Kind, + NamedTypeNode, + ObjectTypeDefinitionNode, + ObjectTypeExtensionNode, + ScalarTypeDefinitionNode, + ScalarTypeExtensionNode, + UnionTypeDefinitionNode, + UnionTypeExtensionNode, +} from 'graphql'; +import {ID_FIELD_NAME} from './constants'; + +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 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: sortNodes(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; +} + +/** + * 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); +} From ed10b0231b06261d429b8d4da18c662a387c178f Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 3 Nov 2023 10:19:35 -0400 Subject: [PATCH 16/27] feat: handle directive and key sorting --- src/schema/Key.ts | 2 +- .../generateFroidSchema.fed-v1.test.ts | 24 ++-- .../generateFroidSchema.fed-v2.test.ts | 24 ++-- src/schema/__tests__/sortDocumentAst.test.ts | 92 ++++++++++++- src/schema/sortDocumentAst.ts | 121 +++++++++++++++++- 5 files changed, 234 insertions(+), 29 deletions(-) diff --git a/src/schema/Key.ts b/src/schema/Key.ts index 5590761..86ad299 100644 --- a/src/schema/Key.ts +++ b/src/schema/Key.ts @@ -236,7 +236,7 @@ export class Key { * @param {string} fields - The selection set fields. * @returns {string} The sorted selection set fields. */ - protected static getSortedSelectionSetFields(fields: string): string { + public static getSortedSelectionSetFields(fields: string): string { const selections = Key.sortSelectionSetByNameAscending( (Key.parseKeyFields(fields).definitions[0] as OperationDefinitionNode) .selectionSet diff --git a/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts b/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts index c66dacc..88ddacc 100644 --- a/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts +++ b/src/schema/__tests__/generateFroidSchema.fed-v1.test.ts @@ -196,7 +196,7 @@ describe('generateFroidSchema for federation v1', () => { 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! sku: String! @external @@ -261,7 +261,7 @@ describe('generateFroidSchema for federation v1', () => { 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! sku: String! @external @@ -327,7 +327,7 @@ describe('generateFroidSchema for federation v1', () => { 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! brand: [Brand!]! @external @@ -405,7 +405,7 @@ describe('generateFroidSchema for federation v1', () => { 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! brand: [Brand!]! @external @@ -549,7 +549,7 @@ describe('generateFroidSchema for federation v1', () => { authorId: String! } - extend type Book implements Node @key(fields: "bookId author { authorId }") { + extend type Book implements Node @key(fields: "author { authorId } bookId") { "The globally unique identifier." id: ID! author: Author! @external @@ -562,7 +562,7 @@ describe('generateFroidSchema for federation v1', () => { 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! sku: String! @external @@ -740,7 +740,7 @@ describe('generateFroidSchema for federation v1', () => { id: ID! } - extend type Product implements Node @key(fields: "upc sku brand { brandId }") { + extend type Product implements Node @key(fields: "brand { brandId } sku upc") { "The globally unique identifier." id: ID! brand: [Brand!]! @external @@ -1042,7 +1042,7 @@ describe('generateFroidSchema for federation v1', () => { directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION - extend type Todo implements Node @key(fields: "todoId customField") { + extend type Todo implements Node @key(fields: "customField todoId") { "The globally unique identifier." id: ID! customField: UsedCustomScalar1 @external @@ -1053,7 +1053,7 @@ describe('generateFroidSchema for federation v1', () => { scalar UsedCustomScalar2 - extend type User implements Node @key(fields: "userId customField1 customField2") { + extend type User implements Node @key(fields: "customField1 customField2 userId") { "The globally unique identifier." id: ID! customField1: UsedCustomScalar1 @external @@ -1332,7 +1332,7 @@ describe('generateFroidSchema for federation v1', () => { directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION - extend type Todo implements Node @key(fields: "todoId customField") { + extend type Todo implements Node @key(fields: "customField todoId") { "The globally unique identifier." id: ID! customField: UsedCustomScalar1 @external @@ -1348,7 +1348,7 @@ describe('generateFroidSchema for federation v1', () => { VALUE_TWO } - extend type User implements Node @key(fields: "userId customField1 customField2 customEnum") { + extend type User implements Node @key(fields: "customEnum customField1 customField2 userId") { "The globally unique identifier." id: ID! customEnum: UsedEnum @external @@ -1501,7 +1501,7 @@ describe('generateFroidSchema for federation v1', () => { fullName: String! @external } - extend type Book implements Node @key(fields: "bookId author { fullName address { postalCode } }") { + extend type Book implements Node @key(fields: "author { address { postalCode } fullName } bookId") { "The globally unique identifier." id: ID! author: Author! @external diff --git a/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts b/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts index 636a670..abe2818 100644 --- a/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts +++ b/src/schema/__tests__/generateFroidSchema.fed-v2.test.ts @@ -243,7 +243,7 @@ describe('generateFroidSchema for federation v2', () => { id: ID! } - type Product implements Node @key(fields: "upc sku") { + type Product implements Node @key(fields: "sku upc") { "The globally unique identifier." id: ID! sku: String! @@ -308,7 +308,7 @@ describe('generateFroidSchema for federation v2', () => { id: ID! } - type Product implements Node @key(fields: "upc sku") { + type Product implements Node @key(fields: "sku upc") { "The globally unique identifier." id: ID! sku: String! @@ -381,7 +381,7 @@ describe('generateFroidSchema for federation v2', () => { 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! brand: [Brand!]! @@ -525,7 +525,7 @@ describe('generateFroidSchema for federation v2', () => { authorId: String! } - type Book implements Node @key(fields: "bookId author { authorId }") { + type Book implements Node @key(fields: "author { authorId } bookId") { "The globally unique identifier." id: ID! author: Author! @@ -538,7 +538,7 @@ describe('generateFroidSchema for federation v2', () => { id: ID! } - type Product implements Node @key(fields: "upc sku") { + type Product implements Node @key(fields: "sku upc") { "The globally unique identifier." id: ID! sku: String! @@ -606,7 +606,7 @@ describe('generateFroidSchema for federation v2', () => { 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! brand: [Brand!]! @@ -788,7 +788,7 @@ describe('generateFroidSchema for federation v2', () => { id: ID! } - type Product implements Node @key(fields: "upc sku brand { brandId }") { + type Product implements Node @key(fields: "brand { brandId } sku upc") { "The globally unique identifier." id: ID! brand: [Brand!]! @@ -1096,7 +1096,7 @@ describe('generateFroidSchema for federation v2', () => { ): Node } - type Todo implements Node @key(fields: "todoId customField") { + type Todo implements Node @key(fields: "customField todoId") { "The globally unique identifier." id: ID! customField: UsedCustomScalar1 @@ -1107,7 +1107,7 @@ describe('generateFroidSchema for federation v2', () => { scalar UsedCustomScalar2 - type User implements Node @key(fields: "userId customField1 customField2") { + type User implements Node @key(fields: "customField1 customField2 userId") { "The globally unique identifier." id: ID! customField1: UsedCustomScalar1 @@ -1391,7 +1391,7 @@ describe('generateFroidSchema for federation v2', () => { ): Node @tag(name: "internal") @tag(name: "storefront") } - type Todo implements Node @key(fields: "todoId customField") { + type Todo implements Node @key(fields: "customField todoId") { "The globally unique identifier." id: ID! customField: UsedCustomScalar1 @@ -1408,7 +1408,7 @@ describe('generateFroidSchema for federation v2', () => { VALUE_TWO @inaccessible } - 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! customEnum1: UsedEnum @@ -1564,7 +1564,7 @@ describe('generateFroidSchema for federation v2', () => { fullName: String! } - type Book implements Node @key(fields: "bookId author { fullName address { postalCode } }") { + type Book implements Node @key(fields: "author { address { postalCode } fullName } bookId") { "The globally unique identifier." id: ID! author: Author! diff --git a/src/schema/__tests__/sortDocumentAst.test.ts b/src/schema/__tests__/sortDocumentAst.test.ts index 48342ef..68b3248 100644 --- a/src/schema/__tests__/sortDocumentAst.test.ts +++ b/src/schema/__tests__/sortDocumentAst.test.ts @@ -34,7 +34,7 @@ describe('sortDocumentAst()', () => { flavor: String! } - type Ape { + type Ape @key(fields: "name armLength") { name: String! @caps(match: "Bob", all: false) armLength: Float! } @@ -53,7 +53,7 @@ describe('sortDocumentAst()', () => { union Animals = Ape | Zebra - type Ape { + type Ape @key(fields: "armLength name") { armLength: Float! name: String! @caps(all: false, match: "Bob") } @@ -84,4 +84,92 @@ describe('sortDocumentAst()', () => { ` ); }); + + 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/sortDocumentAst.ts b/src/schema/sortDocumentAst.ts index ec0dab9..1660e0a 100644 --- a/src/schema/sortDocumentAst.ts +++ b/src/schema/sortDocumentAst.ts @@ -1,6 +1,7 @@ import { ASTNode, ArgumentNode, + ConstDirectiveNode, DefinitionNode, DirectiveDefinitionNode, DirectiveNode, @@ -20,10 +21,12 @@ import { ObjectTypeExtensionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, + StringValueNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, } from 'graphql'; -import {ID_FIELD_NAME} from './constants'; +import {DirectiveName, ID_FIELD_NAME, KeyDirectiveArgument} from './constants'; +import {Key} from './Key'; type NamedNode = | ArgumentNode @@ -140,6 +143,20 @@ 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. * @@ -176,7 +193,7 @@ function sortChildren(node: NamedNode): NamedNode { } const directives = node?.directives - ? {directives: sortNodes(node.directives)} + ? {directives: sortDirectives(sortKeyDirectiveFields(node.directives))} : {}; if ( @@ -234,6 +251,48 @@ function sortChildren(node: NamedNode): NamedNode { } 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. * @@ -261,3 +320,61 @@ function sortByName(a: NamedNode, b: NamedNode): number { } 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); +} From cd3d0cd93fd016b6f301a0bc3ebe284b41080ca8 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 3 Nov 2023 10:41:37 -0400 Subject: [PATCH 17/27] fix: remove federation version from FroidSchema options object --- src/schema/FroidSchema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 03101e9..972be93 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -47,7 +47,6 @@ export type NodeQualifier = ( export type FroidSchemaOptions = { contractTags?: string[]; - federationVersion?: string; keySorter?: KeySorter; nodeQualifier?: NodeQualifier; typeExceptions?: string[]; From 58d2603d8f2ae671669471829e553a9d3e376324 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 3 Nov 2023 11:52:27 -0400 Subject: [PATCH 18/27] refactor: improve FroidSchema performance --- src/schema/FroidSchema.ts | 3 +- src/schema/ObjectType.ts | 195 ++++++++++++++++++++++++++------------ 2 files changed, 139 insertions(+), 59 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 972be93..072188b 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -167,6 +167,7 @@ export class FroidSchema { FroidSchema.removeInterfaceObjects(allDefinitionNodes); this.extensionAndDefinitionNodes = this.getNonRootObjectTypes(); + this.objectTypes = this.getObjectDefinitions(); this.findFroidObjectTypes(); @@ -177,9 +178,9 @@ export class FroidSchema { kind: Kind.DOCUMENT, definitions: [ this.createLinkSchemaExtension(), - ...this.createCustomReturnTypes(), this.createQueryDefinition(), this.createNodeInterface(), + ...this.createCustomReturnTypes(), ...this.createObjectTypesAST(), ], } as DocumentNode); diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 5ba622c..a71e84e 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -11,8 +11,76 @@ const FINAL_KEY_MAX_DEPTH = 100; * Collates information about an object type definition node. */ export class ObjectType { - public readonly typename: string; + /** + * Fields belonging to this object type that were selected for + * use in the keys of other object types. + */ private _externallySelectedFields: string[] = []; + /** + * 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[]; + /** + * The key selected for use in the FROID schema. + */ + public readonly 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" } + */ + public readonly 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'] + */ + public readonly directlySelectedFields: string[]; /** * @@ -32,25 +100,34 @@ export class ObjectType { 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(); } /** - * All occurrences of the node across all subgraph schemas. + * Get all occurrences of the node across all subgraph schemas. * * @returns {ObjectTypeNode[]} The list of occurrences */ - public get occurrences(): ObjectTypeNode[] { + private getOccurrences(): ObjectTypeNode[] { return this.extensionAndDefinitionNodes.filter( (searchNode) => searchNode.name.value === this.node.name.value ); } /** - * All keys applied to all occurrences of the node. + * Get all keys applied to all occurrences of the node. * * @returns {Key[]} The list of keys */ - public get keys(): Key[] { + private getKeys(): Key[] { return this.occurrences.flatMap( (occurrence) => occurrence.directives @@ -60,11 +137,11 @@ export class ObjectType { } /** - * All the child fields from all occurrences of the node. + * Get all the child fields from all occurrences of the node as records. * - * @returns {FieldDefinitionNode[]} The list of fields + * @returns {Record} The of field records */ - public get allFields(): FieldDefinitionNode[] { + private getAllFieldRecords(): Record { const fields: Record = {}; this.occurrences.forEach((occurrence) => occurrence?.fields?.forEach((field) => { @@ -85,14 +162,35 @@ export class ObjectType { }); }); }); - return Object.values(fields).filter(Boolean) as FieldDefinitionNode[]; + 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 - ): FieldDefinitionNode | undefined { + ): void { if ( fields[field.name.value] !== null || (applyNodeQualifier && !this.nodeQualifier(field, this.objectTypes)) @@ -107,58 +205,48 @@ export class ObjectType { } /** - * The names of all the fields that appear the keys of the node. + * Get the names of all the fields that appear in the keys of the node. * * @returns {string[]} The list of key field names */ - public get allKeyFieldsList(): string[] { + private getAllKeyFieldsList(): string[] { return [...new Set(this.keys.flatMap((key) => key.fieldsList))]; } /** - * All the fields that appear in the keys of the node. + * Get all the fields that appear in the keys of the node. * * @returns {FieldDefinitionNode[]} The list of key fields */ - public get allKeyFields(): FieldDefinitionNode[] { + public getAllKeyFields(): FieldDefinitionNode[] { return this.allFields.filter((field) => this.allKeyFieldsList.includes(field.name.value) ); } /** - * The key selected for use in the FROID schema. + * Get the key selected for use in the FROID schema. * * @returns {Key|undefined} The selected key */ - get selectedKey(): Key | undefined { + 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. - * - * Example schema: - * type Book @key(fields: "theBookAuthor { name }") { - * theBookAuthor: Author! - * } - * type Author { - * name - * } - * - * Example record: - * { "theBookAuthor": "Author" } + * 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 get childObjectsInSelectedKey(): Record { + public getChildObjectsInSelectedKey(): Record { const children: Record = {}; - this.allFields.forEach((field) => { - if (!this?.selectedKey?.fieldsList.includes(field.name.value)) { - return; - } + if (!this.selectedKey) { + return children; + } + this.selectedKey.fieldsList.forEach((keyField) => { + const field = this.allFieldRecords[keyField]; const fieldType = FroidSchema.extractFieldType(field); if ( !this.objectTypes.find( @@ -173,28 +261,16 @@ export class ObjectType { } /** - * 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'] + * Get the names of the fields that are being used by the node itself. * * @returns {string[]} The list of field names */ - public get directlySelectedFields(): string[] { - return this.allFields - .filter((field) => - this.selectedKey?.fieldsList.includes(field.name.value) - ) - .map((field) => field.name.value); + public getDirectlySelectedFields(): string[] { + return ( + this.selectedKey?.fieldsList?.filter((keyField) => + Boolean(this.allFieldRecords[keyField]) + ) || [] + ); } /** @@ -223,11 +299,14 @@ export class ObjectType { * @returns {FieldDefinitionNode} The list of fields */ public get selectedFields(): FieldDefinitionNode[] { - return this.allFields.filter( - (field) => - this.directlySelectedFields.includes(field.name.value) || - this.externallySelectedFields.includes(field.name.value) - ); + return [...this.directlySelectedFields, ...this.externallySelectedFields] + .map((keyField) => { + const field = this.allFieldRecords[keyField]; + if (field) { + return field; + } + }) + .filter(Boolean) as FieldDefinitionNode[]; } /** From 3b57919b3b13a7af241f7722172929eb25ffe666 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 3 Nov 2023 14:09:19 -0400 Subject: [PATCH 19/27] fix: prevent duplicate externally used fields --- src/schema/ObjectType.ts | 17 +++-- src/schema/__tests__/FroidSchema.test.ts | 83 +++++++++++++++++++++++- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index a71e84e..aec16d1 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -11,11 +11,6 @@ const FINAL_KEY_MAX_DEPTH = 100; * Collates information about an object type definition node. */ export class ObjectType { - /** - * Fields belonging to this object type that were selected for - * use in the keys of other object types. - */ - private _externallySelectedFields: string[] = []; /** * The name of the object type. */ @@ -81,6 +76,11 @@ export class ObjectType { * ['authorId'] */ public readonly directlySelectedFields: string[]; + /** + * Fields belonging to this object type that were selected for + * use in the keys of other object types. + */ + private _externallySelectedFields: string[] = []; /** * @@ -409,6 +409,11 @@ export class ObjectType { * @returns {void} */ public addExternallySelectedFields(fields: KeyField[]): void { - this._externallySelectedFields.push(...fields.map((field) => field.name)); + fields.forEach((field) => { + if (this._externallySelectedFields.includes(field.name)) { + return; + } + this._externallySelectedFields.push(field.name); + }); } } diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index c96347b..1140730 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -2081,7 +2081,7 @@ describe('FroidSchema class', () => { ); }); - it('Stops recursion when an already-visited ancestor is encountered', () => { + it('stops recursion when an already-visited ancestor is encountered', () => { const bookSchema = gql` type Book @key(fields: "author { name }") { author: Author! @@ -2139,6 +2139,87 @@ describe('FroidSchema class', () => { ` ); }); + + 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 bookId isbn }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "bookId isbn") { + "The globally unique identifier." + id: ID! + bookId: String! + 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 bookId isbn }") { + "The globally unique identifier." + id: ID! + book: Book! + } + ` + ); + }); }); describe('createLinkSchemaExtension() method', () => { From 6ade1681fe339bc4e84924e8a369c06e940241b8 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 3 Nov 2023 15:54:22 -0400 Subject: [PATCH 20/27] fix: fix field qualification logic --- src/schema/ObjectType.ts | 4 +++- src/schema/__tests__/FroidSchema.test.ts | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index aec16d1..3b3b4e1 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -145,7 +145,9 @@ export class ObjectType { const fields: Record = {}; this.occurrences.forEach((occurrence) => occurrence?.fields?.forEach((field) => { - fields[field.name.value] = null; + if (!fields[field.name.value]) { + fields[field.name.value] = null; + } this.addQualifiedField(field, fields); }) ); diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 1140730..368d15d 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -1943,22 +1943,29 @@ describe('FroidSchema class', () => { const bookSchema = gql` type Book @key(fields: "isbn") { isbn: String! - title: String + title: String! } `; const authorSchema = gql` type Book @key(fields: "isbn") { isbn: String! - title: 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, @@ -1967,7 +1974,7 @@ describe('FroidSchema class', () => { nodeQualifier: (node) => { if ( node.kind === Kind.FIELD_DEFINITION && - node.type.kind !== Kind.NON_NULL_TYPE + node.type.kind === Kind.NON_NULL_TYPE ) { return false; } @@ -1990,7 +1997,7 @@ describe('FroidSchema class', () => { "The globally unique identifier." id: ID! isbn: String! - title: String! @external + title: String @external } "The global identification interface implemented by all entities." From 769c9dc2a96540e5da75622255928cb421b7d5ca Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 4 Nov 2023 23:46:23 -0400 Subject: [PATCH 21/27] feat: re-choose selected key based on use in other entity keys --- src/schema/FroidSchema.ts | 8 +- src/schema/ObjectType.ts | 106 +- src/schema/__tests__/FroidSchema.test.ts | 2978 +++++++++++----------- 3 files changed, 1645 insertions(+), 1447 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 072188b..6853c5a 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -340,13 +340,7 @@ export class FroidSchema { existingNode = this.froidObjectTypes[fieldType]; } - existingNode.addExternallySelectedFields( - keyField.selections.filter( - (selection) => - !existingNode.selectedKey || - !existingNode.selectedKey.fieldsList.includes(selection.name) - ) - ); + existingNode.addExternallySelectedFields(keyField.selections); this.generateFroidDependency(keyField.selections, existingNode.allFields); }); diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 3b3b4e1..30fdfdd 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -39,10 +39,15 @@ export class ObjectType { * All the fields that appear in the keys of the node. */ public readonly allKeyFields: FieldDefinitionNode[]; + /** + * 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. */ - public readonly selectedKey: Key | undefined; + 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 @@ -59,7 +64,7 @@ export class ObjectType { * Example record: * { "theBookAuthor": "Author" } */ - public readonly childObjectsInSelectedKey: Record; + private childObjectsInSelectedKey: Record; /** * The names of the fields that are being used by the node itself. * @@ -75,12 +80,7 @@ export class ObjectType { * Example value: * ['authorId'] */ - public readonly directlySelectedFields: string[]; - /** - * Fields belonging to this object type that were selected for - * use in the keys of other object types. - */ - private _externallySelectedFields: string[] = []; + private directlySelectedFields: string[]; /** * @@ -106,7 +106,7 @@ export class ObjectType { this.allFields = this.getAllFields(); this.allKeyFieldsList = this.getAllKeyFieldsList(); this.allKeyFields = this.getAllKeyFields(); - this.selectedKey = this.getSelectedKey(); + this._selectedKey = this.getSelectedKey(); this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); this.directlySelectedFields = this.getDirectlySelectedFields(); } @@ -244,10 +244,10 @@ export class ObjectType { */ public getChildObjectsInSelectedKey(): Record { const children: Record = {}; - if (!this.selectedKey) { + if (!this._selectedKey) { return children; } - this.selectedKey.fieldsList.forEach((keyField) => { + this._selectedKey.fieldsList.forEach((keyField) => { const field = this.allFieldRecords[keyField]; const fieldType = FroidSchema.extractFieldType(field); if ( @@ -269,7 +269,7 @@ export class ObjectType { */ public getDirectlySelectedFields(): string[] { return ( - this.selectedKey?.fieldsList?.filter((keyField) => + this._selectedKey?.fieldsList?.filter((keyField) => Boolean(this.allFieldRecords[keyField]) ) || [] ); @@ -301,7 +301,12 @@ export class ObjectType { * @returns {FieldDefinitionNode} The list of fields */ public get selectedFields(): FieldDefinitionNode[] { - return [...this.directlySelectedFields, ...this.externallySelectedFields] + return [ + ...new Set([ + ...this.directlySelectedFields, + ...this.externallySelectedFields, + ]), + ] .map((keyField) => { const field = this.allFieldRecords[keyField]; if (field) { @@ -333,6 +338,15 @@ export class ObjectType { ); } + /** + * 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. * @@ -343,16 +357,17 @@ export class ObjectType { } /** - * Generated the final key for the node based on all descendant types and their keys (if they have keys). + * 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) { + if (!this._selectedKey) { return; } + if (depth > FINAL_KEY_MAX_DEPTH) { console.error( `Encountered max entity key depth on type '${ @@ -361,9 +376,14 @@ export class ObjectType { ); return; } + + if (!this.selectedKeyMatchesExternalUse()) { + this.selectedExternallyUsedKey(); + } + const mergedKey = new Key( this.node.name.value, - this.selectedKey.toString() + this._selectedKey.toString() ); const selectedKeyFields = [ ...this.selectedKeyFields.map((field) => field.name.value), @@ -404,6 +424,47 @@ export class ObjectType { return mergedKey; } + /** + * Determine whether or not the selected key matches the use of the entity in other + * entity complex keys. + * + * - If the entity has no key, this will return true. + * - If there are no uses of the entity in another entity complex key, this will return true. + * - Otherwise, they key will be compared to use in other entity keys and the validity + * determination will be returned + * + * @returns {boolean} Whether or not the selected key matches external use + */ + private selectedKeyMatchesExternalUse(): boolean { + this._externallySelectedFields; //? + return Boolean( + !this._externallySelectedFields.length || + !this._selectedKey || + this._selectedKey.fieldsList.some((field) => { + return this._externallySelectedFields.includes(field); + }) + ); + } + + /** + * @returns + */ + private selectedExternallyUsedKey() { + const newSelectedKey = this.keys.find((key) => + key.fieldsList.some((field) => + this._externallySelectedFields.includes(field) + ) + ); + if (newSelectedKey) { + // Make the new key official + this._selectedKey = newSelectedKey; + // Update the child objects in the key + this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); + // Update the directly selected fields + this.directlySelectedFields = this.getDirectlySelectedFields(); + } + } + /** * Adds the names of fields used by other entities to the list of externally selected fields. * @@ -411,11 +472,14 @@ export class ObjectType { * @returns {void} */ public addExternallySelectedFields(fields: KeyField[]): void { - fields.forEach((field) => { - if (this._externallySelectedFields.includes(field.name)) { - return; - } - this._externallySelectedFields.push(field.name); + const additionalFields = fields.flatMap((field) => { + const usedKeys = this.keys + .filter((key) => key.fieldsList.includes(field.name)) + .flatMap((key) => key.fieldsList); + return [field.name, ...usedKeys]; }); + this._externallySelectedFields = [ + ...new Set([...this._externallySelectedFields, ...additionalFields]), + ]; } } diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 368d15d..9117f72 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -64,226 +64,7 @@ describe('FroidSchema class', () => { ); }); - 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` - ); - }); - - 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('omits interface object', () => { - 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 @key(fields: "id") directives', () => { - 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('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('defaults to generating valid schema using the first key found regardless of complexity', () => { + it('uses the first entity key found regardless of complexity by default', () => { const productSchema = gql` type Query { topProducts(first: Int = 5): [Product] @@ -359,7 +140,7 @@ describe('FroidSchema class', () => { ); }); - it('generates the correct entities across multiple subgraph services', () => { + it('includes entities from multiple subgraph schemas', () => { const productSchema = gql` type Query { user(id: String): User @@ -468,34 +249,37 @@ describe('FroidSchema class', () => { ); }); - it('generates the correct entities across multiple subgraph services when external entities are used as complex keys', () => { - const productSchema = gql` - type Query { - topProducts(first: Int = 5): [Product] - } + 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 Product @key(fields: "upc sku brand { brandId }") { - upc: String! - sku: String! - name: String - brand: [Brand!]! - price: Int - weight: Int + type Query { + user(id: String): User } - type Brand @key(fields: "brandId", resolvable: false) { - brandId: Int! + type User @key(fields: "userId customField1 customField2") { + userId: String! + name: String! + customField1: UsedCustomScalar1 + customField2: [UsedCustomScalar2!]! + unusedField: UnusedCustomScalar } `; + const todoSchema = gql` + scalar UsedCustomScalar1 - const brandSchema = gql` - type Brand @key(fields: "brandId") @key(fields: "alternateBrandId") { - brandId: Int! + type Todo @key(fields: "todoId customField") { + todoId: Int! + text: String! + complete: Boolean! + customField: UsedCustomScalar1 } `; const subgraphs = new Map(); - subgraphs.set('brand-subgraph', brandSchema); - subgraphs.set('product-subgraph', productSchema); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); const actual = generateSchema({ subgraphs, @@ -508,26 +292,12 @@ describe('FroidSchema class', () => { 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! - brandId: Int! - } - "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 } 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( @@ -535,213 +305,214 @@ describe('FroidSchema class', () => { 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('ignores types that are provided as exceptions to generation', () => { - const userSchema = gql` + it('generates valid schema for entities with multi-field, un-nested complex keys', () => { + const productSchema = gql` type Query { - user(id: String): User - } - - type User @key(fields: "userId") { - userId: String! - name: String! + topProducts(first: Int = 5): [Product] } - `; - const todoSchema = gql` - type Todo @key(fields: "todoId") { - todoId: Int! - text: String! - complete: Boolean! + type Product @key(fields: "upc sku") { + upc: String! + sku: String! + name: String + price: Int + weight: Int } `; const subgraphs = new Map(); - subgraphs.set('user-subgraph', userSchema); - subgraphs.set('todo-subgraph', todoSchema); + subgraphs.set('product-subgraph', productSchema); 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"]) + 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! - } + "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." + type Product implements Node @key(fields: "sku upc") { + "The globally unique identifier." id: ID! - ): Node - } + sku: String! + upc: String! + } - type User implements Node @key(fields: "userId") { - "The globally unique identifier." - id: ID! - userId: String! - } - ` + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` ); }); - it('ignores types based on a custom qualifier function', () => { - const userSchema = gql` + it('generates valid schema for entity with nested complex keys', () => { + const productSchema = gql` type Query { - user(id: String): User + topProducts(first: Int = 5): [Product] } - type User @key(fields: "userId") { - userId: String! - name: String! + 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 Todo @key(fields: "oldTodoKey") { - oldTodoKey: String! + type Brand { + brandId: Int! + store: Store } - `; - const todoSchema = gql` - type Todo @key(fields: "todoId") @key(fields: "oldTodoKey") { - todoId: Int! - oldTodoKey: String! - text: String! - complete: Boolean! + type Store { + storeId: Int! } `; 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; + subgraphs.set('product-subgraph', productSchema); 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"]) + 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 Brand { + brandId: Int! + store: Store + } - type Query { - "Fetches an entity by its globally unique identifier." - node( - "A globally unique entity identifier." + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." id: ID! - ): Node - } + } - type Todo implements Node @key(fields: "todoId") { - "The globally unique identifier." - id: ID! - todoId: Int! - } + 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 User implements Node @key(fields: "userId") { - "The globally unique identifier." - id: ID! - userId: 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('ignores the existing relay subgraph when generating types', () => { - const userSchema = gql` - type Query { - user(id: String): User + it('finds the complete schema cross-subgraph', () => { + const magazineSchema = gql` + type Magazine + @key(fields: "magazineId publisher { address { country } }") { + magazineId: String! + publisher: Publisher! } - type User @key(fields: "userId") { - userId: String! - name: String! - } - `; - const todoSchema = gql` - type Todo @key(fields: "todoId") { - todoId: Int! - text: String! - complete: Boolean! + type Publisher { + address: Address! } - type User @key(fields: "userId", resolvable: false) { - userId: String! + type Address { + country: 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 + const bookSchema = gql` + type Book + @key(fields: "bookId author { fullName address { postalCode } }") { + bookId: String! + title: String! + author: Author! + } - "The global identification interface implemented by all entities." - interface Node { - "The globally unique identifier." - id: ID! + type Author @key(fields: "authorId") { + authorId: Int! + fullName: String! + address: Address! } - type Query { - "Fetches an entity by its globally unique identifier." - node( - "A globally unique entity identifier." - id: ID! - ): Node + type Address { + postalCode: String! + country: String! } + `; - type Todo implements Node @key(fields: "todoId") { - "The globally unique identifier." - id: ID! - todoId: Int! + const authorSchema = gql` + type Author @key(fields: "authorId") { + authorId: Int! + fullName: String! + address: Address! } - type User implements Node @key(fields: "userId") { - "The globally unique identifier." - id: ID! - userId: String! + type Address { + postalCode: String! + country: String! } `; + const subgraphs = new Map(); - subgraphs.set('user-subgraph', userSchema); - subgraphs.set('todo-subgraph', todoSchema); - subgraphs.set('relay-subgraph', relaySchema); + 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, }); @@ -750,70 +521,81 @@ describe('FroidSchema class', () => { 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 Address { + country: String! + postalCode: String! } - type Todo implements Node @key(fields: "todoId") { + type Author implements Node @key(fields: "authorId") { "The globally unique identifier." id: ID! - todoId: Int! + address: Address! @external + authorId: Int! + fullName: String! @external } - type User implements Node @key(fields: "userId") { + type Book implements Node @key(fields: "author { __typename address { __typename postalCode } authorId fullName } bookId") { "The globally unique identifier." id: ID! - userId: String! + 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('generates custom scalar definitions when they are used on a type definition in the schema', () => { - const userSchema = gql` - scalar UsedCustomScalar1 - scalar UsedCustomScalar2 - scalar UnusedCustomScalar - - type Query { - user(id: String): User + 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! } + `; - type User @key(fields: "userId customField1 customField2") { - userId: String! + const authorSchema = gql` + type Author @key(fields: "book { isbn }") { name: String! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! - unusedField: UnusedCustomScalar + book: Book! } - `; - const todoSchema = gql` - scalar UsedCustomScalar1 - type Todo @key(fields: "todoId customField") { - todoId: Int! - text: String! - complete: Boolean! - customField: UsedCustomScalar1 + type Book @key(fields: "isbn") { + isbn: String! } `; + const subgraphs = new Map(); - subgraphs.set('user-subgraph', userSchema); - subgraphs.set('todo-subgraph', todoSchema); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); const actual = generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], federationVersion: FED2_DEFAULT_VERSION, }); @@ -822,8 +604,20 @@ describe('FroidSchema class', () => { 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 { + interface Node @tag(name: "internal") @tag(name: "storefront") { "The globally unique identifier." id: ID! } @@ -833,90 +627,118 @@ describe('FroidSchema class', () => { node( "A globally unique entity identifier." id: ID! - ): Node + ): Node @tag(name: "internal") @tag(name: "storefront") } + ` + ); + }); - type Todo implements Node @key(fields: "customField todoId") { + 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! - customField: UsedCustomScalar1 - todoId: Int! + book: Book! } - scalar UsedCustomScalar1 - - scalar UsedCustomScalar2 + type Book implements Node @key(fields: "isbn title") { + "The globally unique identifier." + id: ID! + isbn: String! + title: String! + } - type User implements Node @key(fields: "customField1 customField2 userId") { + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { "The globally unique identifier." id: ID! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! - userId: String! + } + + 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('only includes descriptions for schema 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 + 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! } + `; - "User description" - type User @key(fields: "userId address { postalCode }") { - "userId description" - userId: String! - "Name description" + const authorSchema = gql` + type Author @key(fields: "book { isbn }") { name: String! - "Unused field description" - unusedField: UnusedCustomScalar - "Address field description" - address: Address! + book: Book! } - """ - Address type description - """ - type Address { - "postalCode field description" - postalCode: String! + type Book @key(fields: "isbn") { + isbn: String! } `; - const todoSchema = gql` - scalar UsedCustomScalar1 + const reviewSchema = gql` + type Review @key(fields: "book { bookId }") { + averageRating: Float! + book: Book! + } - """ - Todo type description - """ - type Todo @key(fields: "todoId customField") { - "todoId field description" - todoId: Int! - text: String! - complete: Boolean! - customField: UsedCustomScalar1 + type Book @key(fields: "bookId") { + bookId: Int! } `; const subgraphs = new Map(); - subgraphs.set('user-subgraph', userSchema); - subgraphs.set('todo-subgraph', todoSchema); + 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, }); @@ -925,12 +747,22 @@ describe('FroidSchema class', () => { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Address { - postalCode: String! + 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 { + interface Node @tag(name: "internal") @tag(name: "storefront") { "The globally unique identifier." id: ID! } @@ -940,291 +772,339 @@ describe('FroidSchema class', () => { 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! + ): Node @tag(name: "internal") @tag(name: "storefront") } - scalar UsedCustomScalar1 - - type User implements Node @key(fields: "address { __typename postalCode } userId") { + type Review implements Node @key(fields: "book { __typename bookId isbn title }") { "The globally unique identifier." id: ID! - address: Address! - userId: String! + book: Book! } ` ); }); - describe('when using contracts with @tag', () => { - it('propogates valid 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, - }); + 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! + } - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + type Author @key(fields: "authorId") { + authorId: Int! + name: String! + } + `; - "The global identification interface implemented by all entities." - interface Node @tag(name: "storefront") @tag(name: "supplier") { - "The globally unique identifier." - id: ID! - } + const authorSchema = gql` + type Author @key(fields: "authorId") { + authorId: Int! + name: String! + } + `; - type Product implements Node @key(fields: "upc") { - "The globally unique identifier." - id: ID! - upc: String! - } + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); - 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") - } - ` - ); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, }); - it('uses the first entity key, regardless of tagging or accessibility, and accurately tags the id field', () => { - const productSchema = gql` - type Query { - topProducts(first: Int = 5): [Product] - } + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Product @key(fields: "upc") @key(fields: "name") { - upc: String! @inaccessible - name: String @tag(name: "storefront") - price: Int - weight: Int + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + authorId: Int! + name: String! @external } - `; - const subgraphs = new Map(); - subgraphs.set('product-subgraph', productSchema); - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'supplier'], - federationVersion: FED2_DEFAULT_VERSION, - }); + type Book implements Node @key(fields: "author { __typename authorId name }") { + "The globally unique identifier." + id: ID! + author: Author! + } - 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! + } - "The global identification interface implemented by all entities." - interface Node @tag(name: "storefront") @tag(name: "supplier") { - "The globally unique identifier." + 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 Product implements Node @key(fields: "upc") { - "The globally unique identifier." - id: ID! @tag(name: "storefront") - upc: String! - } + 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 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 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, }); - it('propagates tags to the id field based on tags of sibling fields across subgraphs', () => { - const productSchema = gql` - type Query { - user(id: String): User - } + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + authorId: Int! + name: String! @external } - type Brand @key(fields: "brandId") { - brandId: Int! @tag(name: "storefront") @tag(name: "internal") - name: String @tag(name: "storefront") @tag(name: "internal") + type Book implements Node @key(fields: "author { __typename authorId name }") { + "The globally unique identifier." + id: ID! + author: Author! } - type StorefrontUser @key(fields: "userId") { - userId: String! @tag(name: "storefront") @tag(name: "internal") - name: String! @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 InternalUser @key(fields: "userId") { - userId: String! @tag(name: "internal") - name: String! @tag(name: "internal") + 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") } - `; + ` + ); + }); - const todoSchema = gql` - type StorefrontUser @key(fields: "userId") { - userId: String! - todos: [Todo!]! @tag(name: "internal") - } + 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 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); + 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', - contractTags: ['storefront', 'supplier'], - federationVersion: FED2_DEFAULT_VERSION, - }); + 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` + 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") { + type Author implements Node @key(fields: "book { __typename isbn title }") { "The globally unique identifier." - id: ID! @tag(name: "internal") @tag(name: "storefront") - brandId: Int! + id: ID! + book: Book! } - type InternalUser implements Node @key(fields: "userId") { + type Book implements Node @key(fields: "isbn") { "The globally unique identifier." - id: ID! @tag(name: "internal") - userId: String! + id: ID! + isbn: String! + title: String @external } "The global identification interface implemented by all entities." - interface Node @tag(name: "storefront") @tag(name: "supplier") { + interface Node { "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") + ): Node } + ` + ); + }); - type StorefrontUser implements Node @key(fields: "userId") { + 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! @tag(name: "internal") @tag(name: "storefront") - userId: String! + id: ID! + book: Book! } - type Todo implements Node @key(fields: "todoId") { + type Book implements Node @key(fields: "isbn") { "The globally unique identifier." - id: ID! @tag(name: "internal") - todoId: Int! + id: ID! + isbn: String! + title: String @external } - ` - ); - }); - it('generates custom scalar definitions w/global tags when they are used on a type definition in the schema', () => { - const userSchema = gql` - scalar UsedCustomScalar1 - scalar UsedCustomScalar2 - scalar UnusedCustomScalar + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } - enum UsedEnum { - VALUE_ONE @customDirective - VALUE_TWO @customDirective @inaccessible - VALUE_THREE - } + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } + ` + ); + }); - type Query { - user(id: String): User - } + 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 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 Author @key(fields: "book { title }") { + book: Book! + name: String! + } + `; - 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 subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], - federationVersion: FED2_DEFAULT_VERSION, - }); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); - expect(actual).toEqual( - // prettier-ignore - gql` + 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." @@ -1238,69 +1118,173 @@ describe('FroidSchema class', () => { 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! - } + 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! + } + `; - scalar UsedCustomScalar1 + const authorSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } - scalar UsedCustomScalar2 + type Author @key(fields: "book { isbn }") { + book: Book! + } + `; - enum UsedEnum { - VALUE_ONE - VALUE_THREE - VALUE_TWO @inaccessible - } + const reviewSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } - type User implements Node @key(fields: "customEnum1 customEnum2 customField1 customField2 userId") { - "The globally unique identifier." + 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! - customEnum1: UsedEnum - customEnum2: [UsedEnum!]! - customField1: UsedCustomScalar1 - customField2: [UsedCustomScalar2!]! - userId: String! + book: Book! } - ` - ); - }); - it('tags are identified from field arguments', () => { - const urlSchema = gql` - type TypeA @key(fields: "selections { selectionId }") { - selections: [TypeB!] @inaccessible - fieldWithArgument(argument: Int @tag(name: "storefront")): Boolean + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! } - type TypeB @key(fields: "selectionId", resolvable: false) { - selectionId: String! + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! } - `; - const altSchema = gql` - type TypeB @key(fields: "selectionId") { - selectionId: String! @tag(name: "storefront") + 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") } - `; - const subgraphs = new Map(); - subgraphs.set('url-subgraph', urlSchema); - subgraphs.set('alt-subgraph', altSchema); + type Review implements Node @key(fields: "book { __typename isbn }") { + "The globally unique identifier." + id: ID! + book: Book! + } + ` + ); + }); - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], - federationVersion: FED2_DEFAULT_VERSION, - }); + 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` + 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." @@ -1329,74 +1313,89 @@ describe('FroidSchema class', () => { selectionId: String! } ` - ); - }); + ); }); - describe('when generating schema for complex keys', () => { - it('uses a custom key sorter to prefer the first complex key', () => { - const productSchema = gql` - type Query { - topProducts(first: Int = 5): [Product] - } + 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 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 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 { - brandId: Int! - store: Store - name: String! - } + type Brand @key(fields: "brandId") { + brandId: Int! @tag(name: "storefront") @tag(name: "internal") + name: String @tag(name: "storefront") @tag(name: "internal") + } - type Store { - storeId: Int! - } - `; - const subgraphs = new Map(); - subgraphs.set('product-subgraph', productSchema); + type StorefrontUser @key(fields: "userId") { + userId: String! @tag(name: "storefront") @tag(name: "internal") + name: String! @tag(name: "storefront") + } - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - keySorter: (keys) => { - return keys.sort((a, b) => { - return b.depth - a.depth; - }); - }, - federationVersion: FED2_DEFAULT_VERSION, - }); + 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` + expect(actual).toEqual( + // prettier-ignore + gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Brand { + type Brand implements Node @key(fields: "brandId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") brandId: Int! - store: Store + } + + 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 { + interface Node @tag(name: "storefront") @tag(name: "supplier") { "The globally unique identifier." id: ID! } - type Product implements Node @key(fields: "brand { __typename brandId store { __typename storeId } }") { + type Product implements Node @key(fields: "upc") { "The globally unique identifier." - id: ID! - brand: [Brand!]! + id: ID! @tag(name: "internal") @tag(name: "storefront") + upc: String! } type Query { @@ -1404,68 +1403,61 @@ describe('FroidSchema class', () => { node( "A globally unique entity identifier." id: ID! - ): Node + ): Node @tag(name: "storefront") @tag(name: "supplier") } - type Store { - storeId: Int! + type StorefrontUser implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") @tag(name: "storefront") + userId: String! } - ` - ); - }); - it('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 Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! @tag(name: "internal") + todoId: Int! + } + ` + ); + }); - type Brand { - brandId: Int! - store: Store - } + 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 Store { - storeId: Int! - } - `; - const subgraphs = new Map(); - subgraphs.set('product-subgraph', productSchema); + 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', - keySorter: (keys) => keys, - federationVersion: FED2_DEFAULT_VERSION, - }); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'supplier'], + federationVersion: FED2_DEFAULT_VERSION, + }); - expect(actual).toEqual( - // prettier-ignore - gql` + 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 { + 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! + id: ID! @tag(name: "storefront") upc: String! } @@ -1474,393 +1466,592 @@ describe('FroidSchema class', () => { node( "A globally unique entity identifier." id: ID! - ): Node + ): Node @tag(name: "storefront") @tag(name: "supplier") } ` - ); - }); + ); + }); - it('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] + 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 Product - @key(fields: "upc sku") - @key(fields: "upc sku brand { brandId }") { - upc: String! - sku: String! - name: String - brand: [Brand!]! - price: Int - weight: 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") } - type Brand { - brandId: Int! - store: Store + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! } - type Book - @key(fields: "bookId") - @key(fields: "bookId author { authorId }") { - bookId: String! - author: Author! + scalar UsedCustomScalar1 + + scalar UsedCustomScalar2 + + enum UsedEnum { + VALUE_ONE + VALUE_THREE + VALUE_TWO @inaccessible } - type Author { - authorId: String! + 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! } - `; - 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, - }); + it('ignores keys that use the `id` field', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } - type Author { - authorId: String! - } + type Brand @key(fields: "id") { + id: ID! + name: String + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); - type Book implements Node @key(fields: "author { __typename authorId } bookId") { - "The globally unique identifier." - id: ID! - author: Author! - bookId: String! - } + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); - "The global identification interface implemented by all entities." - interface Node { - "The globally unique identifier." + 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 + } + ` + ); + }); - type Product implements Node @key(fields: "sku upc") { - "The globally unique identifier." + 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! - sku: String! - upc: String! - } + ): Node + } - 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, }); - it('generates valid schema for entity with nested complex (multi-field) keys', () => { - const productSchema = gql` - type Query { - topProducts(first: Int = 5): [Product] - } + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - 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 + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! } - 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! + type Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: 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 User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! + } + ` + ); + }); - 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! - } + it('ignores descriptions for schema that is not owned by the FROID subgraph', () => { + const userSchema = gql` + "Scalar description" + scalar UsedCustomScalar1 - type Query { - "Fetches an entity by its globally unique identifier." - node( - "A globally unique entity identifier." - id: ID! - ): Node - } + """ + Another scalar description + """ + scalar UsedCustomScalar2 - type Store { - storeId: Int! - } - ` - ); - }); + scalar UnusedCustomScalar - it('generates valid schema for entity with complex (multi-field) keys', () => { - const productSchema = gql` - type Query { - topProducts(first: Int = 5): [Product] - } + type Query { + user(id: String): User + } - 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); + "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! + } - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - federationVersion: FED2_DEFAULT_VERSION, - }); + """ + Address type description + """ + type Address { + "postalCode field description" + postalCode: String! + } + `; - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + const todoSchema = gql` + scalar UsedCustomScalar1 - "The global identification interface implemented by all entities." - interface Node { - "The globally unique identifier." - id: ID! - } + """ + Todo type description + """ + type Todo @key(fields: "todoId customField") { + "todoId field description" + todoId: Int! + text: String! + complete: Boolean! + customField: UsedCustomScalar1 + } + `; - type Product implements Node @key(fields: "sku upc") { - "The globally unique identifier." - id: ID! - sku: String! - upc: String! - } + const subgraphs = new Map(); + subgraphs.set('user-subgraph', userSchema); + subgraphs.set('todo-subgraph', todoSchema); - type Query { - "Fetches an entity by its globally unique identifier." - node( - "A globally unique entity identifier." - id: ID! - ): Node - } - ` - ); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, }); - 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! - } + 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! } - `; - const bookSchema = gql` - type Book - @key(fields: "bookId author { fullName address { postalCode } }") { - bookId: String! - title: String! - author: Author! + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! } - type Author @key(fields: "authorId") { - authorId: Int! - fullName: String! - address: Address! + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node } - type Address { - postalCode: String! - country: String! + type Todo implements Node @key(fields: "customField todoId") { + "The globally unique identifier." + id: ID! + customField: UsedCustomScalar1 + todoId: Int! } - `; - const authorSchema = gql` - type Author @key(fields: "authorId") { - authorId: Int! - fullName: String! + scalar UsedCustomScalar1 + + type User implements Node @key(fields: "address { __typename postalCode } userId") { + "The globally unique identifier." + id: ID! address: Address! + userId: String! } + ` + ); + }); - type Address { - postalCode: String! - country: String! - } - `; + it('ignores the existing relay subgraph when generating types', () => { + const userSchema = gql` + type Query { + user(id: String): User + } - const subgraphs = new Map(); - subgraphs.set('magazine-subgraph', magazineSchema); - subgraphs.set('book-subgraph', bookSchema); - subgraphs.set('author-subgraph', authorSchema); + type User @key(fields: "userId") { + userId: String! + name: String! + } + `; + const todoSchema = gql` + type Todo @key(fields: "todoId") { + todoId: Int! + text: String! + complete: Boolean! + } - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], - federationVersion: FED2_DEFAULT_VERSION, - }); + 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! + } - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + directive @tag( + name: String! + ) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION - type Address { - country: String! - postalCode: String! - } + "The global identification interface implemented by all entities." + interface Node { + "The globally unique identifier." + id: ID! + } - type Author implements Node @key(fields: "authorId") { - "The globally unique identifier." + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." id: ID! - address: Address! @external - authorId: Int! - fullName: String! @external - } + ): Node + } - 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 Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: Int! + } - type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country } }") { - "The globally unique identifier." - id: ID! - magazineId: String! - publisher: Publisher! - } + 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 @tag(name: "internal") @tag(name: "storefront") { + interface Node { "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") + ): Node } - ` - ); - }); - 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 Todo implements Node @key(fields: "todoId") { + "The globally unique identifier." + id: ID! + todoId: Int! } - type Author @key(fields: "authorId") { - authorId: Int! - name: String! @shareable + type User implements Node @key(fields: "userId") { + "The globally unique identifier." + id: ID! + userId: String! } - `; + ` + ); + }); - const authorSchema = gql` - type Author @key(fields: "authorId") { - authorId: Int! - name: String! @shareable - } - `; + it('does not propagate miscellaneous directives to the generated id field', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } - const subgraphs = new Map(); - subgraphs.set('book-subgraph', bookSchema); - subgraphs.set('author-subgraph', authorSchema); + 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', - contractTags: ['storefront', 'internal'], - federationVersion: FED2_DEFAULT_VERSION, - }); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + }); - expect(actual).toEqual( - // prettier-ignore - gql` + 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 global identification interface implemented by all entities." + interface Node { "The globally unique identifier." id: ID! - author: Author! } - "The global identification interface implemented by all entities." - interface Node @tag(name: "internal") @tag(name: "storefront") { + type Product implements Node @key(fields: "upc") { "The globally unique identifier." id: ID! + upc: String! } type Query { @@ -1868,142 +2059,145 @@ describe('FroidSchema class', () => { node( "A globally unique entity identifier." id: ID! - ): Node @tag(name: "internal") @tag(name: "storefront") + ): Node } ` - ); - }); - - 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! - } - `; + it('can uses a custom key sorter to prefer the first complex key', () => { + const productSchema = gql` + type Query { + topProducts(first: Int = 5): [Product] + } - const authorSchema = gql` - type Author @key(fields: "authorId") { - authorId: Int! - name: String! - } - `; + 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 + } - const subgraphs = new Map(); - subgraphs.set('book-subgraph', bookSchema); - subgraphs.set('author-subgraph', authorSchema); + type Brand { + brandId: Int! + store: Store + name: String! + } - const actual = generateSchema({ - subgraphs, - froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], - federationVersion: FED2_DEFAULT_VERSION, - }); + type Store { + storeId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + keySorter: (keys) => { + return keys.sort((a, b) => { + return b.depth - a.depth; + }); + }, + federationVersion: FED2_DEFAULT_VERSION, + }); - type Author implements Node @key(fields: "authorId") { - "The globally unique identifier." - id: ID! - authorId: Int! - name: String! @external - } + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Book implements Node @key(fields: "author { __typename authorId name }") { - "The globally unique identifier." - id: ID! - author: Author! - } + type Brand { + brandId: Int! + store: Store + } - "The global identification interface implemented by all entities." - interface Node @tag(name: "internal") @tag(name: "storefront") { - "The globally unique identifier." - id: ID! - } + "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." + type Product implements Node @key(fields: "brand { __typename brandId store { __typename storeId } }") { + "The globally unique identifier." id: ID! - ): Node @tag(name: "internal") @tag(name: "storefront") - } - ` - ); - }); + brand: [Brand!]! + } - 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 Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node + } - 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); + 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 + } - 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; - }, - }); + type Store { + storeId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); - expect(actual).toEqual( - // prettier-ignore - gql` - extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + keySorter: (keys) => keys, + federationVersion: FED2_DEFAULT_VERSION, + }); - type Author implements Node @key(fields: "book { __typename isbn title }") { - "The globally unique identifier." - id: ID! - book: Book! - } + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) - type Book implements Node @key(fields: "isbn") { + "The global identification interface implemented by all entities." + interface Node { "The globally unique identifier." id: ID! - isbn: String! - title: String @external } - "The global identification interface implemented by all entities." - interface Node { + type Product implements Node @key(fields: "upc") { "The globally unique identifier." id: ID! + upc: String! } type Query { @@ -2014,61 +2208,71 @@ describe('FroidSchema class', () => { ): 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] - } + 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 Author @key(fields: "book { title }") { - book: Book! - } - `; - const subgraphs = new Map(); - subgraphs.set('book-subgraph', bookSchema); - subgraphs.set('author-subgraph', authorSchema); + type Product + @key(fields: "upc sku") + @key(fields: "upc sku brand { brandId }") { + upc: String! + sku: String! + name: String + brand: [Brand!]! + price: Int + weight: Int + } - 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; - }, - }); + 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` + 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 Author { + authorId: String! } - type Book implements Node @key(fields: "isbn") { + type Book implements Node @key(fields: "author { __typename authorId } bookId") { "The globally unique identifier." id: ID! - isbn: String! - title: String @external + author: Author! + bookId: String! } "The global identification interface implemented by all entities." @@ -2077,6 +2281,13 @@ describe('FroidSchema class', () => { 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( @@ -2085,148 +2296,77 @@ describe('FroidSchema class', () => { ): Node } ` - ); - }); - - it('stops 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"]) + it('generates schema document AST', () => { + const productSchema = gql` + type Product @key(fields: "upc") { + upc: String! + } + `; + const subgraphs = new Map(); + subgraphs.set('product-subgraph', productSchema); - type Author implements Node @key(fields: "book { __typename author { __typename name } title }") { - "The globally unique identifier." - id: ID! - book: Book! - name: String! @external - } + const froid = new FroidSchema( + 'relay-subgraph', + FED2_DEFAULT_VERSION, + subgraphs, + {} + ); - type Book implements Node @key(fields: "author { __typename book { __typename title } name }") { - "The globally unique identifier." - id: ID! - author: Author! - title: String! @external - } + expect(froid.toAst().kind).toEqual(Kind.DOCUMENT); + }); - "The global identification interface implemented by all entities." - interface Node @tag(name: "internal") @tag(name: "storefront") { - "The globally unique identifier." - id: ID! - } + 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); - 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") - } - ` - ); + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: 'v2.3', }); - 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! - } - `; + expect(actual).toMatch( + 'extend schema @link(url: "https://specs.apollo.dev/federation/v2.3"' + ); + }); - const subgraphs = new Map(); - subgraphs.set('book-subgraph', bookSchema); - subgraphs.set('author-subgraph', authorSchema); - subgraphs.set('review-subgraph', reviewSchema); + 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); - const actual = generateSchema({ + let errorMessage = ''; + try { + generateSchema({ subgraphs, froidSubgraphName: 'relay-subgraph', - contractTags: ['storefront', 'internal'], - federationVersion: FED2_DEFAULT_VERSION, + federationVersion: 'v3.1', }); + } catch (err) { + errorMessage = err.message; + } - 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 }") { - "The globally unique identifier." - id: ID! - book: Book! - } - - type Book implements Node @key(fields: "bookId isbn") { - "The globally unique identifier." - id: ID! - bookId: String! - 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 bookId isbn }") { - "The globally unique identifier." - id: ID! - book: Book! - } - ` - ); - }); + expect(errorMessage).toMatch( + `Federation version must be a valid 'v2.x' version` + ); }); describe('createLinkSchemaExtension() method', () => { From 3f9736ee1f68938fab77909f6c324cabeea6e957 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 4 Nov 2023 23:59:53 -0400 Subject: [PATCH 22/27] feat: add tests for some additional key selection scenarios --- src/schema/ObjectType.ts | 3 +- src/schema/__tests__/FroidSchema.test.ts | 89 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 30fdfdd..5f1c774 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -436,7 +436,6 @@ export class ObjectType { * @returns {boolean} Whether or not the selected key matches external use */ private selectedKeyMatchesExternalUse(): boolean { - this._externallySelectedFields; //? return Boolean( !this._externallySelectedFields.length || !this._selectedKey || @@ -447,7 +446,7 @@ export class ObjectType { } /** - * @returns + * Assigns an externally used key as the selected key. */ private selectedExternallyUsedKey() { const newSelectedKey = this.keys.find((key) => diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 9117f72..5539954 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -784,6 +784,95 @@ describe('FroidSchema class', () => { ); }); + 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('applies the @external directive to non-key fields used by other entity keys', () => { const bookSchema = gql` type Book @key(fields: "author { name }") { From 90d7a51a3f714c86412f18e7efd4846f8313934d Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sun, 5 Nov 2023 23:51:35 -0500 Subject: [PATCH 23/27] refactor: refactor ObjectType and FroidSchema classes --- src/schema/FroidSchema.ts | 49 ++------- src/schema/ObjectType.ts | 202 ++++++++++++++++++++++++++++---------- 2 files changed, 159 insertions(+), 92 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 6853c5a..46bc37d 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -8,7 +8,6 @@ import { FieldDefinitionNode, InterfaceTypeDefinitionNode, Kind, - NamedTypeNode, ObjectTypeDefinitionNode, ScalarTypeDefinitionNode, SchemaExtensionNode, @@ -22,14 +21,12 @@ import { CONTRACT_DIRECTIVE_NAME, DEFAULT_FEDERATION_LINK_IMPORTS, DirectiveName, - EXTERNAL_DIRECTIVE_AST, FED2_OPT_IN_URL, FED2_VERSION_PREFIX, ID_FIELD_NAME, ID_FIELD_TYPE, } from './constants'; import assert from 'assert'; -import {implementsNodeInterface} from './astDefinitions'; import {Key} from './Key'; import {KeyField} from './KeyField'; import {ObjectType} from './ObjectType'; @@ -192,40 +189,8 @@ export class FroidSchema { * @returns {ObjectTypeDefinitionNode[]} The generated object types. */ private createObjectTypesAST(): ObjectTypeDefinitionNode[] { - return Object.values(this.froidObjectTypes).map( - ({node, finalKey, selectedKeyFields, selectedNonKeyFields}) => { - let froidFields: FieldDefinitionNode[] = []; - let externalFieldDirectives: ConstDirectiveNode[] = []; - let froidInterfaces: NamedTypeNode[] = []; - if (FroidSchema.isEntity(node)) { - froidFields = [ - FroidSchema.createIdField(this.getTagDirectivesForIdField(node)), - ]; - externalFieldDirectives = [EXTERNAL_DIRECTIVE_AST]; - froidInterfaces = [implementsNodeInterface]; - } - const fields = [ - ...froidFields, - ...selectedKeyFields.map((field) => ({ - ...field, - description: undefined, - directives: [], - })), - ...selectedNonKeyFields.map((field) => ({ - ...field, - description: undefined, - directives: externalFieldDirectives, - })), - ]; - const finalKeyDirective = finalKey?.toDirective(); - return { - ...node, - description: undefined, - interfaces: froidInterfaces, - directives: [...(finalKeyDirective ? [finalKeyDirective] : [])], - fields, - }; - } + return Object.values(this.froidObjectTypes).map((froidObject) => + froidObject.toAst() ); } @@ -627,7 +592,7 @@ export class FroidSchema { * @param {string} name - The name of the tag * @returns {ConstDirectiveNode} A directive AST node for @tag */ - private static createTagDirective(name: string): ConstDirectiveNode { + public static createTagDirective(name: string): ConstDirectiveNode { return { kind: Kind.DIRECTIVE, name: {kind: Kind.NAME, value: CONTRACT_DIRECTIVE_NAME}, @@ -667,7 +632,7 @@ export class FroidSchema { * @param {ConstDirectiveNode[]} directives - The directives to add to the field definition * @returns {FieldDefinitionNode} The `id` field definition */ - private static createIdField( + public static createIdField( directives: ConstDirectiveNode[] = [] ): FieldDefinitionNode { return { @@ -828,21 +793,21 @@ export class FroidSchema { * @param {ObjectTypeNode[]} nodes - The nodes to check * @returns {boolean} Whether or not any nodes are entities */ - private static isEntity(nodes: ObjectTypeNode[]); + 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 */ - private static isEntity(node: ObjectTypeNode); + 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 */ - private static isEntity(node: ObjectTypeNode | ObjectTypeNode[]): boolean { + public static isEntity(node: ObjectTypeNode | ObjectTypeNode[]): boolean { const nodesToCheck = Array.isArray(node) ? node : [node]; return nodesToCheck.some((node) => node?.directives?.some( diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 5f1c774..6fa6139 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -1,9 +1,16 @@ -import {FieldDefinitionNode, ObjectTypeDefinitionNode} from 'graphql'; +import { + ConstDirectiveNode, + FieldDefinitionNode, + NamedTypeNode, + ObjectTypeDefinitionNode, + StringValueNode, +} from 'graphql'; import {Key} from './Key'; -import {DirectiveName} from './constants'; +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; @@ -39,6 +46,10 @@ export class ObjectType { * 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. @@ -109,6 +120,7 @@ export class ObjectType { this._selectedKey = this.getSelectedKey(); this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); this.directlySelectedFields = this.getDirectlySelectedFields(); + this.isEntity = FroidSchema.isEntity(this.occurrences); } /** @@ -118,7 +130,7 @@ export class ObjectType { */ private getOccurrences(): ObjectTypeNode[] { return this.extensionAndDefinitionNodes.filter( - (searchNode) => searchNode.name.value === this.node.name.value + (searchNode) => searchNode.name.value === this.typename ); } @@ -132,7 +144,7 @@ export class ObjectType { (occurrence) => occurrence.directives ?.filter((directive) => directive.name.value === DirectiveName.Key) - .map((key) => new Key(this.node.name.value, key)) || [] + .map((key) => new Key(this.typename, key)) || [] ); } @@ -377,23 +389,23 @@ export class ObjectType { return; } - if (!this.selectedKeyMatchesExternalUse()) { - this.selectedExternallyUsedKey(); + const usedKeys = this.getUsedKeys(); + const updatedSelectedKey = usedKeys.shift(); + + if (!updatedSelectedKey) { + return; } - const mergedKey = new Key( - this.node.name.value, - this._selectedKey.toString() - ); + // 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 = [ - ...this.selectedKeyFields.map((field) => field.name.value), + ...usedKeys.flatMap((key) => key.toString()), ].join(' '); if (selectedKeyFields) { - const keyFromSelections = new Key( - this.node.name.value, - selectedKeyFields - ); + const keyFromSelections = new Key(this.typename, selectedKeyFields); mergedKey.merge(keyFromSelections); } Object.entries(this.childObjectsInSelectedKey).forEach( @@ -415,7 +427,7 @@ export class ObjectType { return; } const keyToMerge = new Key( - this.node.name.value, + this.typename, `${dependentField} { ${dependencyFinalKey.toString()} }` ); mergedKey.merge(keyToMerge); @@ -425,43 +437,110 @@ export class ObjectType { } /** - * Determine whether or not the selected key matches the use of the entity in other - * entity complex keys. - * - * - If the entity has no key, this will return true. - * - If there are no uses of the entity in another entity complex key, this will return true. - * - Otherwise, they key will be compared to use in other entity keys and the validity - * determination will be returned + * Assigns a new selected key. * - * @returns {boolean} Whether or not the selected key matches external use - */ - private selectedKeyMatchesExternalUse(): boolean { - return Boolean( - !this._externallySelectedFields.length || - !this._selectedKey || - this._selectedKey.fieldsList.some((field) => { - return this._externallySelectedFields.includes(field); - }) - ); + * @param {Key} key - The key + */ + private setSelectedKey(key: Key) { + this._selectedKey = key; + this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); + this.directlySelectedFields = this.getDirectlySelectedFields(); } /** - * Assigns an externally used key as the selected key. + * Gets the keys used either by default or in other entity keys. + * + * @returns {Key[]} The used keys */ - private selectedExternallyUsedKey() { - const newSelectedKey = this.keys.find((key) => + private getUsedKeys(): Key[] { + if (!this.isEntity || !this._selectedKey) { + return []; + } + + const usedExternalKeys = this.keys.filter((key) => key.fieldsList.some((field) => this._externallySelectedFields.includes(field) ) ); - if (newSelectedKey) { - // Make the new key official - this._selectedKey = newSelectedKey; - // Update the child objects in the key - this.childObjectsInSelectedKey = this.getChildObjectsInSelectedKey(); - // Update the directly selected fields - this.directlySelectedFields = this.getDirectlySelectedFields(); + 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) + ); } /** @@ -471,14 +550,37 @@ export class ObjectType { * @returns {void} */ public addExternallySelectedFields(fields: KeyField[]): void { - const additionalFields = fields.flatMap((field) => { - const usedKeys = this.keys - .filter((key) => key.fieldsList.includes(field.name)) - .flatMap((key) => key.fieldsList); - return [field.name, ...usedKeys]; - }); this._externallySelectedFields = [ - ...new Set([...this._externallySelectedFields, ...additionalFields]), + ...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, + }; + } } From bc02566e6507053e4f84f4ca01eca3d4381aa690 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 6 Nov 2023 11:57:01 -0500 Subject: [PATCH 24/27] test: add additional test --- src/schema/__tests__/FroidSchema.test.ts | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index 5539954..57c5c05 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -873,6 +873,101 @@ describe('FroidSchema class', () => { ); }); + 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 }") { From 2802d96aeb7716a4beb2799da2454b2849f3630d Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 6 Nov 2023 12:31:16 -0500 Subject: [PATCH 25/27] refactor: remove unused code --- src/schema/ObjectType.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 6fa6139..5cd4044 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -328,28 +328,6 @@ export class ObjectType { .filter(Boolean) as FieldDefinitionNode[]; } - /** - * The list of selected fields that appear in any of the node's keys. - * - * @returns {FieldDefinitionNode[]} The list of key fields - */ - public get selectedKeyFields(): FieldDefinitionNode[] { - return this.selectedFields.filter((field) => - this.allKeyFieldsList.includes(field.name.value) - ); - } - - /** - * The list of selected fields that do not appear in any of the node's keys. - * - * @returns {FieldDefinitionNode[]} The list of non-key fields - */ - public get selectedNonKeyFields(): FieldDefinitionNode[] { - return this.selectedFields.filter( - (field) => !this.allKeyFieldsList.includes(field.name.value) - ); - } - /** * The key selected for use in the FROID schema. * From c5c46d7e468c82f886f2fb0f8637d53c4ca44d71 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Tue, 7 Nov 2023 14:05:06 -0500 Subject: [PATCH 26/27] refactor: apply PR suggestion --- src/schema/FroidSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 46bc37d..0832483 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -131,7 +131,7 @@ export class FroidSchema { this.federationVersion = federationVersion; assert( - this.federationVersion.indexOf(FED2_VERSION_PREFIX) > -1, + this.federationVersion.startsWith(FED2_VERSION_PREFIX), `Federation version must be a valid '${FED2_VERSION_PREFIX}x' version. Examples: v2.0, v2.3` ); From 959002e8826e42da4bd13cd7c773ba19ee2652ef Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Thu, 9 Nov 2023 14:27:31 -0500 Subject: [PATCH 27/27] chore: bump version and update changelog --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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",