import { ConstArgumentNode, ASTNode, buildASTSchema as buildGraphqlSchemaFromAST, DirectiveLocation, ConstDirectiveNode, ConstValueNode, DocumentNode, GraphQLError, GraphQLSchema, Kind, ListTypeNode, NamedTypeNode, parse, TypeNode, VariableDefinitionNode, VariableNode, SchemaDefinitionNode, TypeDefinitionNode, DefinitionNode, DirectiveDefinitionNode, DirectiveNode, } from "graphql"; import { CoreImport, CoreOrLinkDirectiveArgs, CoreSpecDefinition, extractCoreFeatureImports, FeatureUrl, FeatureVersion, findCoreSpecVersion, isCoreSpecDirectiveApplication, removeAllCoreFeatures, } from "./specs/coreSpec"; import { assert, assertUnreachable, mapValues, MapWithCachedArrays, removeArrayElement, SetMultiMap } from "./utils"; import { withDefaultValues, valueEquals, valueToString, valueToAST, valueFromAST, valueNodeToConstValueNode, argumentsEquals, collectVariablesInValue } from "./values"; import { tagIdentity } from "./specs/tagSpec"; import { inaccessibleIdentity, removeInaccessibleElements } from "./specs/inaccessibleSpec"; import { printDirectiveDefinition, printSchema } from './print'; import { sameType } from './types'; import { addIntrospectionFields, introspectionFieldNames, isIntrospectionName } from "./introspection"; import { validateSDL } from "graphql/validation/validate"; import { SDLValidationRule } from "graphql/validation/ValidationContext"; import { specifiedSDLRules } from "graphql/validation/specifiedRules"; import { validateSchema } from "./validate"; import { createDirectiveSpecification, createScalarTypeSpecification, DirectiveSpecification, TypeSpecification } from "./directiveAndTypeSpecification"; import { didYouMean, suggestionList } from "./suggestions"; import { aggregateError, ERRORS, withModifiedErrorMessage } from "./error"; import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures"; const validationErrorCode = 'GraphQLValidationFailed'; const DEFAULT_VALIDATION_ERROR_MESSAGE = 'The schema is not a valid GraphQL schema.'; const EMPTY_SET = new Set(); export const ErrGraphQLValidationFailed = (causes: GraphQLError[], message: string = DEFAULT_VALIDATION_ERROR_MESSAGE) => aggregateError(validationErrorCode, message, causes); const apiSchemaValidationErrorCode = 'GraphQLAPISchemaValidationFailed'; export const ErrGraphQLAPISchemaValidationFailed = (causes: GraphQLError[]) => aggregateError(apiSchemaValidationErrorCode, 'The supergraph schema failed to produce a valid API schema', causes); export const typenameFieldName = '__typename'; export type QueryRootKind = 'query'; export type MutationRootKind = 'mutation'; export type SubscriptionRootKind = 'subscription'; export type SchemaRootKind = QueryRootKind | MutationRootKind | SubscriptionRootKind; export const allSchemaRootKinds: SchemaRootKind[] = ['query', 'mutation', 'subscription']; export function defaultRootName(rootKind: SchemaRootKind): string { return rootKind.charAt(0).toUpperCase() + rootKind.slice(1); } function checkDefaultSchemaRoot(type: NamedType): SchemaRootKind | undefined { if (type.kind !== 'ObjectType') { return undefined; } switch (type.name) { case 'Query': return 'query'; case 'Mutation': return 'mutation'; case 'Subscription': return 'subscription'; default: return undefined; } } export function isSchemaRootType(type: NamedType): boolean { return isObjectType(type) && type.isRootType(); } export type Type = NamedType | WrapperType; export type NamedType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | InputObjectType; export type OutputType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | ListType | NonNullType; export type InputType = ScalarType | EnumType | InputObjectType | ListType | NonNullType; export type WrapperType = ListType | NonNullType; export type AbstractType = InterfaceType | UnionType; export type CompositeType = ObjectType | InterfaceType | UnionType; export type OutputTypeReferencer = FieldDefinition; export type InputTypeReferencer = InputFieldDefinition | ArgumentDefinition; export type ObjectTypeReferencer = OutputTypeReferencer | UnionType | SchemaDefinition; export type InterfaceTypeReferencer = OutputTypeReferencer | ObjectType | InterfaceType; export type NullableType = NamedType | ListType; export type NamedTypeKind = NamedType['kind']; export function isNamedType(type: Type): type is NamedType { return type instanceof BaseNamedType; } export function isWrapperType(type: Type): type is WrapperType { return isListType(type) || isNonNullType(type); } export function isListType(type: Type): type is ListType { return type.kind == 'ListType'; } export function isNonNullType(type: Type): type is NonNullType { return type.kind == 'NonNullType'; } export function isScalarType(type: Type): type is ScalarType { return type.kind == 'ScalarType'; } export function isCustomScalarType(type: Type): boolean { return isScalarType(type) && !graphQLBuiltInTypes.includes(type.name); } export function isIntType(type: Type): boolean { return type === type.schema().intType(); } export function isStringType(type: Type): boolean { return type === type.schema().stringType(); } export function isFloatType(type: Type): boolean { return type === type.schema().floatType(); } export function isBooleanType(type: Type): boolean { return type === type.schema().booleanType(); } export function isIDType(type: Type): boolean { return type === type.schema().idType(); } export function isObjectType(type: Type): type is ObjectType { return type.kind == 'ObjectType'; } export function isInterfaceType(type: Type): type is InterfaceType { return type.kind == 'InterfaceType'; } export function isEnumType(type: Type): type is EnumType { return type.kind == 'EnumType'; } export function isUnionType(type: Type): type is UnionType { return type.kind == 'UnionType'; } export function isInputObjectType(type: Type): type is InputObjectType { return type.kind == 'InputObjectType'; } export function isOutputType(type: Type): type is OutputType { switch (baseType(type).kind) { case 'ScalarType': case 'ObjectType': case 'UnionType': case 'EnumType': case 'InterfaceType': return true; default: return false; } } export function isInputType(type: Type): type is InputType { switch (baseType(type).kind) { case 'ScalarType': case 'EnumType': case 'InputObjectType': return true; default: return false; } } export function isTypeOfKind(type: Type, kind: T['kind']): type is T { return type.kind === kind; } export function filterTypesOfKind(types: readonly Type[], kind: T['kind']): T[] { return types.reduce( (acc: T[], type: Type) => { if (isTypeOfKind(type, kind)) { acc.push(type); } return acc; }, [], ); } export function baseType(type: Type): NamedType { return isWrapperType(type) ? type.baseType() : type; } export function isNullableType(type: Type): boolean { return !isNonNullType(type); } export function isAbstractType(type: Type): type is AbstractType { return isInterfaceType(type) || isUnionType(type); } export function isCompositeType(type: Type): type is CompositeType { return isObjectType(type) || isInterfaceType(type) || isUnionType(type); } export function possibleRuntimeTypes(type: CompositeType): readonly ObjectType[] { switch (type.kind) { case 'InterfaceType': return type.possibleRuntimeTypes(); case 'UnionType': return type.types(); case 'ObjectType': return [type]; } } export function runtimeTypesIntersects(t1: CompositeType, t2: CompositeType): boolean { if (t1 === t2) { return true; } const rt1 = possibleRuntimeTypes(t1); const rt2 = possibleRuntimeTypes(t2); for (const obj1 of rt1) { if (rt2.some(obj2 => obj1.name === obj2.name)) { return true; } } return false; } export function supertypes(type: CompositeType): readonly CompositeType[] { switch (type.kind) { case 'InterfaceType': return type.interfaces(); case 'UnionType': return []; case 'ObjectType': return (type.interfaces() as CompositeType[]).concat(type.unionsWhereMember()); } } export function isConditionalDirective(directive: Directive | DirectiveDefinition): boolean { return ['include', 'skip'].includes(directive.name); } export const executableDirectiveLocations: DirectiveLocation[] = [ DirectiveLocation.QUERY, DirectiveLocation.MUTATION, DirectiveLocation.SUBSCRIPTION, DirectiveLocation.FIELD, DirectiveLocation.FRAGMENT_DEFINITION, DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT, DirectiveLocation.VARIABLE_DEFINITION, ]; const executableDirectiveLocationsSet = new Set(executableDirectiveLocations); export function isExecutableDirectiveLocation(loc: DirectiveLocation): boolean { return executableDirectiveLocationsSet.has(loc); } export const typeSystemDirectiveLocations: DirectiveLocation[] = [ DirectiveLocation.SCHEMA, DirectiveLocation.SCALAR, DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.ARGUMENT_DEFINITION, DirectiveLocation.INTERFACE, DirectiveLocation.UNION, DirectiveLocation.ENUM, DirectiveLocation.ENUM_VALUE, DirectiveLocation.INPUT_OBJECT, DirectiveLocation.INPUT_FIELD_DEFINITION, ]; const typeSystemDirectiveLocationsSet = new Set(typeSystemDirectiveLocations); export function isTypeSystemDirectiveLocation(loc: DirectiveLocation): boolean { return typeSystemDirectiveLocationsSet.has(loc); } /** * Converts a type to an AST of a "reference" to that type, one corresponding to the type `toString()` (and thus never a type definition). * * To print a type definition, see the `printTypeDefinitionAndExtensions` method. */ export function typeToAST(type: Type): TypeNode { switch (type.kind) { case 'ListType': return { kind: Kind.LIST_TYPE, type: typeToAST(type.ofType) }; case 'NonNullType': return { kind: Kind.NON_NULL_TYPE, type: typeToAST(type.ofType) as NamedTypeNode | ListTypeNode }; default: return { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: type.name } }; } } export function typeFromAST(schema: Schema, node: TypeNode): Type { switch (node.kind) { case Kind.LIST_TYPE: return new ListType(typeFromAST(schema, node.type)); case Kind.NON_NULL_TYPE: return new NonNullType(typeFromAST(schema, node.type) as NullableType); default: const type = schema.type(node.name.value); if (!type) { throw ERRORS.INVALID_GRAPHQL.err(`Unknown type "${node.name.value}"`, { nodes: node }); } return type; } } export type LeafType = ScalarType | EnumType; export function isLeafType(type: Type): type is LeafType { return isScalarType(type) || isEnumType(type); } export interface Named { readonly name: string; } export type ExtendableElement = SchemaDefinition | NamedType; export class DirectiveTargetElement> { readonly appliedDirectives: Directive[]; constructor( private readonly _schema: Schema, directives: readonly Directive[] = [], ) { this.appliedDirectives = directives.map((d) => this.attachDirective(d)); } schema(): Schema { return this._schema; } private attachDirective(directive: Directive): Directive { // if the directive is not attached, we can assume we're fine just attaching it to use. Otherwise, we're "copying" it. const toAdd = directive.isAttached() ? new Directive(directive.name, directive.arguments()) : directive; Element.prototype['setParent'].call(toAdd, this); return toAdd; } appliedDirectivesOf(nameOrDefinition: string | DirectiveDefinition): Directive[] { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this.appliedDirectives.filter(d => d.name == directiveName) as Directive[]; } hasAppliedDirective(nameOrDefinition: string | DirectiveDefinition): boolean { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this.appliedDirectives.some(d => d.name == directiveName); } appliedDirectivesToDirectiveNodes() : ConstDirectiveNode[] | undefined { return directivesToDirectiveNodes(this.appliedDirectives); } appliedDirectivesToString(): string { return directivesToString(this.appliedDirectives); } collectVariablesInAppliedDirectives(collector: VariableCollector) { for (const applied of this.appliedDirectives) { collector.collectInArguments(applied.arguments()); } } } export function sourceASTs(...elts: ({ sourceAST?: TNode } | undefined)[]): TNode[] { return elts.map(elt => elt?.sourceAST).filter((elt): elt is TNode => elt !== undefined); } // Not exposed: mostly about avoid code duplication between SchemaElement and Directive (which is not a SchemaElement as it can't // have applied directives or a description abstract class Element | Schema | DirectiveTargetElement> { protected _parent?: TParent; sourceAST?: ASTNode; schema(): Schema { const schema = this.schemaInternal(); assert(schema, 'requested schema does not exist. Probably because the element is unattached'); return schema; } // this function exists because sometimes we can have an element that will be attached soon even though the current state is unattached // (mainly for callbacks). Sometimes these intermediate states need to get the schema if it exists, but it may not. // all external clients should use schema() protected schemaInternal(): Schema | undefined { if (!this._parent) { return undefined; } else if (this._parent instanceof Schema) { // Note: at the time of this writing, it seems like typescript type-checking breaks a bit around generics. // At this point of the code, `this._parent` is typed as 'TParent & Schema', but for some reason this is // "not assignable to type 'Schema | undefined'" (which sounds wrong: if my type theory is not too broken, // 'A & B' should always be assignable to both 'A' and 'B'). return this._parent as any; } else if (this._parent instanceof SchemaElement) { return this._parent.schemaInternal(); } else if (this._parent instanceof DirectiveTargetElement) { return this._parent.schema(); } assert(false, 'unreachable code. parent is of unknown type'); } get parent(): TParent { assert(this._parent, 'trying to access non-existent parent'); return this._parent; } isAttached(): boolean { return !!this._parent; } // Accessed only through Element.prototype['setParent'] (so we don't mark it protected as an override wouldn't be properly called). private setParent(parent: TParent) { assert(!this._parent, "Cannot set parent of an already attached element"); this._parent = parent; this.onAttached(); } protected onAttached() { // Nothing by default, but can be overriden. } protected checkUpdate() { // Allowing to add element to a detached element would get hairy. Because that would mean that when you do attach an element, // you have to recurse within that element to all children elements to check whether they are attached or not and to which // schema. And if they aren't attached, attaching them as side-effect could be surprising (think that adding a single field // to a schema could bring a whole hierarchy of types and directives for instance). If they are attached, it only work if // it's to the same schema, but you have to check. // Overall, it's simpler to force attaching elements before you add other elements to them. assert(this.isAttached(), () => `Cannot modify detached element ${this}`); } } export class Extension { protected _extendedElement?: TElement; sourceAST?: ASTNode; get extendedElement(): TElement | undefined { return this._extendedElement; } private setExtendedElement(element: TElement) { assert(!this._extendedElement, "Cannot attached already attached extension"); this._extendedElement = element; } } type UnappliedDirective = { nameOrDef: DirectiveDefinition> | string, args: Record, extension?: Extension, directive: DirectiveNode, }; // TODO: ideally, we should hide the ctor of this class as we rely in places on the fact the no-one external defines new implementations. export abstract class SchemaElement, TParent extends SchemaElement | Schema> extends Element { protected _appliedDirectives: Directive[] | undefined; protected _unappliedDirectives: UnappliedDirective[] | undefined; description?: string; addUnappliedDirective({ nameOrDef, args, extension, directive }: UnappliedDirective) { const toAdd = { nameOrDef, args: args ?? {}, extension, directive, }; if (this._unappliedDirectives) { this._unappliedDirectives.push(toAdd); } else { this._unappliedDirectives = [toAdd]; } } processUnappliedDirectives() { for (const { nameOrDef, args, extension, directive } of this._unappliedDirectives ?? []) { const d = this.applyDirective(nameOrDef, args); d.setOfExtension(extension); d.sourceAST = directive; } this._unappliedDirectives = undefined; } get appliedDirectives(): readonly Directive[] { return this._appliedDirectives ?? []; } appliedDirectivesOf(nameOrDefinition: string | DirectiveDefinition): Directive[] { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this.appliedDirectives.filter(d => d.name == directiveName) as Directive[]; } hasAppliedDirective(nameOrDefinition: string | DirectiveDefinition): boolean { // From the type-system point of view, there is no `appliedDirectivesOf(_: string | DirectiveDefinition)` function, but rather 2 overloads, neither of // which can take 'string | DirectiveDefinition', hence the need for this surprisingly looking code. And we don't really want to remove the overloading // on `appliedDirectivesOf` because that would lose us the type-checking of arguments in the case where we pass a definition (or rather, we could // preserve it, but it would make is a bit too easy to mess up calls with the 'string' argument). return (typeof nameOrDefinition === 'string' ? this.appliedDirectivesOf(nameOrDefinition) : this.appliedDirectivesOf(nameOrDefinition) ).length !== 0; } applyDirective( nameOrDef: DirectiveDefinition | string, args?: TApplicationArgs, asFirstDirective: boolean = false, ): Directive { let toAdd: Directive; if (typeof nameOrDef === 'string') { this.checkUpdate(); toAdd = new Directive(nameOrDef, args ?? Object.create(null)); const def = this.schema().directive(nameOrDef) ?? this.schema().blueprint.onMissingDirectiveDefinition(this.schema(), toAdd); if (!def) { throw this.schema().blueprint.onGraphQLJSValidationError( this.schema(), ERRORS.INVALID_GRAPHQL.err(`Unknown directive "@${nameOrDef}".`) ); } if (Array.isArray(def)) { throw ErrGraphQLValidationFailed(def); } } else { this.checkUpdate(nameOrDef); toAdd = new Directive(nameOrDef.name, args ?? Object.create(null)); } Element.prototype['setParent'].call(toAdd, this); // TODO: we should typecheck arguments or our TApplicationArgs business is just a lie. if (this._appliedDirectives) { if (asFirstDirective) { this._appliedDirectives.unshift(toAdd); } else { this._appliedDirectives.push(toAdd); } } else { this._appliedDirectives = [toAdd]; } DirectiveDefinition.prototype['addReferencer'].call(toAdd.definition!, toAdd); this.onModification(); return toAdd; } protected removeAppliedDirectives() { // We copy the array because this._appliedDirectives is modified in-place by `directive.remove()` if (!this._appliedDirectives) { return; } const applied = this._appliedDirectives.concat(); applied.forEach(d => d.remove()); } protected onModification() { const schema = this.schemaInternal(); if (schema) { Schema.prototype['onModification'].call(schema); } } protected isElementBuiltIn(): boolean { return false; } protected removeTypeReferenceInternal(type: BaseNamedType) { // This method is a bit of a hack: we don't want to expose it and we call it from an other class, so we call it though // `SchemaElement.prototype`, but we also want this to abstract as it can only be implemented by each concrete subclass. // As we can't have both at the same time, this method just delegate to `remoteTypeReference` which is genuinely // abstract. This also allow to work around the typing issue that the type checker cannot tell that every BaseNamedType // is a NamedType (because in theory, someone could extend BaseNamedType without listing it in NamedType; but as // BaseNamedType is not exported and we don't plan to make that mistake ...). this.removeTypeReference(type as any); } protected abstract removeTypeReference(type: NamedType): void; protected checkRemoval() { assert(!this.isElementBuiltIn() || Schema.prototype['canModifyBuiltIn'].call(this.schema()), () => `Cannot modify built-in ${this}`); // We allow removals even on detached element because that doesn't particularly create issues (and we happen to do such // removals on detached internally; though of course we could refactor the code if we wanted). } protected checkUpdate(addedElement?: { schema(): Schema, isAttached(): boolean }) { super.checkUpdate(); if (!Schema.prototype['canModifyBuiltIn'].call(this.schema())) { // Ensure this element (the modified one), is not a built-in, or part of one. let thisElement: SchemaElement | Schema | undefined = this; while (thisElement && thisElement instanceof SchemaElement) { assert(!thisElement.isElementBuiltIn(), () => `Cannot modify built-in (or part of built-in) ${this}`); thisElement = thisElement.parent; } } if (addedElement && addedElement.isAttached()) { const thatSchema = addedElement.schema(); assert(!thatSchema || thatSchema === this.schema(), () => `Cannot add element ${addedElement} to ${this} as it is attached to another schema`); } } } // TODO: ideally, we should hide the ctor of this class as we rely in places on the fact the no-one external defines new implementations. export abstract class NamedSchemaElement, TParent extends NamedSchemaElement | Schema, TReferencer> extends SchemaElement implements Named { // We want to be able to rename some elements, but we prefer offering that through a `rename` // method rather than exposing a name setter, as this feel more explicit (but that's arguably debatable). // We also currently only offer renames on types (because that's the only one we currently need), // though we could expand that. protected _name: string; constructor(name: string) { super(); this._name = name; } get name(): string { return this._name; } abstract coordinate: string; abstract remove(): TReferencer[]; } abstract class BaseNamedType> extends NamedSchemaElement { protected _referencers?: Set; protected _extensions?: Extension[]; public preserveEmptyDefinition: boolean = false; constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); } private addReferencer(referencer: TReferencer) { this._referencers ??= new Set(); this._referencers.add(referencer); } private removeReferencer(referencer: TReferencer) { this._referencers?.delete(referencer) } get coordinate(): string { return this.name; } *allChildElements(): Generator, void, undefined> { // Overriden by those types that do have children } extensions(): readonly Extension[] { return this._extensions ?? []; } hasExtension(extension: Extension): boolean { return this._extensions?.includes(extension) ?? false; } newExtension(): Extension { return this.addExtension(new Extension()); } addExtension(extension: Extension): Extension { this.checkUpdate(); // Let's be nice and not complaint if we add an extension already added. if (this.hasExtension(extension)) { return extension; } assert(!extension.extendedElement, () => `Cannot add extension to type ${this}: it is already added to another type`); if (this._extensions) { this._extensions.push(extension); } else { this._extensions = [ extension ]; } Extension.prototype['setExtendedElement'].call(extension, this); this.onModification(); return extension; } removeExtensions() { if (!this._extensions) { return; } this._extensions = undefined; for (const directive of this.appliedDirectives) { directive.removeOfExtension(); } this.removeInnerElementsExtensions(); } isIntrospectionType(): boolean { return isIntrospectionName(this.name); } hasExtensionElements(): boolean { return !!this._extensions; } hasNonExtensionElements(): boolean { return this.preserveEmptyDefinition || this.appliedDirectives.some(d => d.ofExtension() === undefined) || this.hasNonExtensionInnerElements(); } protected abstract hasNonExtensionInnerElements(): boolean; protected abstract removeInnerElementsExtensions(): void; protected isElementBuiltIn(): boolean { return this.isBuiltIn; } rename(newName: string) { // Mostly called to ensure we don't rename built-in types. It does mean we can't renamed detached // types while this wouldn't be dangerous, but it's probably not a big deal (the API is designed // in such a way that you probably should avoid reusing detached elements). this.checkUpdate(); const oldName = this._name; this._name = newName; Schema.prototype['renameTypeInternal'].call(this._parent, oldName, newName); this.onModification(); } /** * Removes this type definition from its parent schema. * * After calling this method, this type will be "detached": it will have no parent, schema, fields, * values, directives, etc... * * Note that it is always allowed to remove a type, but this may make a valid schema * invalid, and in particular any element that references this type will, after this call, have an undefined * reference. * * @returns an array of all the elements in the schema of this type (before the removal) that were * referencing this type (and have thus now an undefined reference). */ remove(): TReferencer[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this type's children. this.sourceAST = undefined; this.removeAppliedDirectives(); this.removeInnerElements(); // Remove this type's references. const toReturn: TReferencer[] = []; this._referencers?.forEach(r => { SchemaElement.prototype['removeTypeReferenceInternal'].call(r, this); toReturn.push(r); }); this._referencers = undefined; // Remove this type from its parent schema. Schema.prototype['removeTypeInternal'].call(this._parent, this); this._parent = undefined; return toReturn; } /** * Removes this this definition _and_, recursively, any other elements that references this type and would be invalid * after the removal. * * Note that contrarily to `remove()` (which this method essentially call recursively), this method leaves the schema * valid (assuming it was valid beforehand) _unless_ all the schema ends up being removed through recursion (in which * case this leaves an empty schema, and that is not technically valid). * * Also note that this method does _not_ necessarily remove all the elements that reference this type: for instance, * if this type is an interface, objects implementing it will _not_ be removed, they will simply stop implementing * the interface. In practice, this method mainly remove fields that were using the removed type (in either argument or * return type), but it can also remove object/input object/interface if through such field removal some type ends up * empty, and it can remove unions if through that removal process and union becomes empty. */ removeRecursive(): void { this.remove().forEach(ref => this.removeReferenceRecursive(ref)); } protected abstract removeReferenceRecursive(ref: TReferencer): void; referencers(): ReadonlySet { return this._referencers ?? EMPTY_SET; } isReferenced(): boolean { return !!this._referencers; } protected abstract removeInnerElements(): void; toString(): string { return this.name; } } // TODO: ideally, we should hide the ctor of this class as we rely in places on the fact the no-one external defines new implementations. export abstract class NamedSchemaElementWithType, P extends NamedSchemaElement | Schema, Referencer> extends NamedSchemaElement { private _type?: TType; get type(): TType | undefined { return this._type; } set type(type: TType | undefined) { if (type) { this.checkUpdate(type); } else { this.checkRemoval(); } if (this._type) { removeReferenceToType(this, this._type); } this._type = type; if (type) { addReferenceToType(this, type); } } protected removeTypeReference(type: NamedType) { // We shouldn't have been listed as a reference if we're not one, so make it sure. assert(this._type && baseType(this._type) === type, () => `Cannot remove reference to type ${type} on ${this} as its type is ${this._type}`); this._type = undefined; } } abstract class BaseExtensionMember extends Element { private _extension?: Extension; ofExtension(): Extension | undefined { return this._extension; } removeOfExtension() { this._extension = undefined; } setOfExtension(extension: Extension | undefined) { this.checkUpdate(); assert(!extension || this._parent?.hasExtension(extension), () => `Cannot set object as part of the provided extension: it is not an extension of parent ${this.parent}`); this._extension = extension; } remove() { this.removeInner(); Schema.prototype['onModification'].call(this.schema()); this._extension = undefined; this._parent = undefined; } protected abstract removeInner(): void; } export class SchemaBlueprint { onMissingDirectiveDefinition(_schema: Schema, _directive: Directive): DirectiveDefinition | GraphQLError[] | undefined { // No-op by default, but used for federation. return undefined; } onDirectiveDefinitionAndSchemaParsed(_: Schema): GraphQLError[] { // No-op by default, but used for federation. return []; } ignoreParsedField(_type: NamedType, _fieldName: string): boolean { // No-op by default, but used for federation. return false; } onConstructed(_: Schema) { // No-op by default, but used for federation. } onAddedCoreFeature(_schema: Schema, _feature: CoreFeature) { // No-op by default, but used for federation. } onInvalidation(_: Schema) { // No-op by default, but used for federation. } onValidation(_schema: Schema): GraphQLError[] { // No-op by default, but used for federation. return [] } validationRules(): readonly SDLValidationRule[] { return specifiedSDLRules; } /** * Allows to intercept some graphQL-js error messages when we can provide additional guidance to users. */ onGraphQLJSValidationError(schema: Schema, error: GraphQLError): GraphQLError { // For now, the main additional guidance we provide is around directives, where we could provide additional help in 2 main ways: // - if a directive name is likely misspelled (somehow, graphQL-js has methods to offer suggestions on likely mispelling, but don't use this (at the // time of this writting) for directive names). // - for fed 2 schema, if a federation directive is refered under it's "default" naming but is not properly imported (not enforced // in the method but rather in the `FederationBlueprint`). // // Note that intercepting/parsing error messages to modify them is never ideal, but pragmatically, it's probably better than rewriting the relevant // rules entirely (in that later case, our "copied" rule would stop getting any potential graphQL-js made improvements for instance). And while such // parsing is fragile, in that it'll break if the original message change, we have unit tests to surface any such breakage so it's not really a risk. const matcher = /^Unknown directive "@(?[_A-Za-z][_0-9A-Za-z]*)"\.$/.exec(error.message); const name = matcher?.groups?.directive; if (!name) { return error; } const allDefinedDirectiveNames = schema.allDirectives().map((d) => d.name); const suggestions = suggestionList(name, allDefinedDirectiveNames); if (suggestions.length === 0) { return this.onUnknownDirectiveValidationError(schema, name, error); } else { return withModifiedErrorMessage(error, `${error.message}${didYouMean(suggestions.map((s) => '@' + s))}`); } } onUnknownDirectiveValidationError(_schema: Schema, _unknownDirectiveName: string, error: GraphQLError): GraphQLError { return error; } applyDirectivesAfterParsing() { return false; } } export const defaultSchemaBlueprint = new SchemaBlueprint(); export class CoreFeature { constructor( readonly url: FeatureUrl, readonly nameInSchema: string, readonly directive: Directive, readonly imports: CoreImport[], readonly purpose?: string, ) { } isFeatureDefinition(element: NamedType | DirectiveDefinition): boolean { const importName = element.kind === 'DirectiveDefinition' ? '@' + element.name : element.name; return element.name.startsWith(this.nameInSchema + '__') || (element.kind === 'DirectiveDefinition' && element.name === this.nameInSchema) || !!this.imports.find((i) => importName === (i.as ?? i.name)); } directiveNameInSchema(name: string): string { return CoreFeature.directiveNameInSchemaForCoreArguments( this.url, this.nameInSchema, this.imports, name, ); } static directiveNameInSchemaForCoreArguments( specUrl: FeatureUrl, specNameInSchema: string, imports: CoreImport[], directiveNameInSpec: string, ): string { const elementImport = imports.find((i) => i.name.charAt(0) === '@' && i.name.slice(1) === directiveNameInSpec ); return elementImport ? (elementImport.as?.slice(1) ?? directiveNameInSpec) : (directiveNameInSpec === specUrl.name ? specNameInSchema : specNameInSchema + '__' + directiveNameInSpec ); } typeNameInSchema(name: string): string { const elementImport = this.imports.find((i) => i.name === name); return elementImport ? (elementImport.as ?? name) : this.nameInSchema + '__' + name; } minimumFederationVersion(): FeatureVersion | undefined { return coreFeatureDefinitionIfKnown(this.url)?.minimumFederationVersion; } } export type ImportConflictsByIdentity = Map< string, { self: Set, other: Set } >; export class CoreFeatures { readonly coreDefinition: CoreSpecDefinition; /** * For specs, a map from their name-in-schemas (a.k.a. aliases) to their * CoreFeatures. */ private readonly byAlias: Map = new Map(); /** * For specs, a map from their identities to their CoreFeatures plus another * map from imported type/directive name-in-specs to name-in-schemas. Like * imports, we distinguish types from directives by using a leading "@". */ private readonly byIdentity: Map]> = new Map(); /** * For imported types/directives, this is a map from their name-in-schemas to * their CoreFeatures plus name-in-specs. Like imports, we distinguish types * from directives by using a leading "@". */ private readonly byImportName: Map = new Map(); /** * For composed elements, merge will generally keep the name-in-schemas of * spec elements in subgraphs as a way to minimize conflicts while keeping * element names predictable for user-defined downstream code. However, merge * will also sometimes change the spec of certain spec elements (e.g. of a * federation spec directive). The result of this is that sometimes elements * using a default name of one spec may be imported using another spec, so we * need to permit e.g. the cost spec to import "@cost" as "@federation__cost" * in the supergraph schema. This kind of thing is generally fine, provided * the old spec alias is no longer in use in the supergraph schema. * * So whenever an import occurs with a name-in-schema that uses a spec alias * prefix that isn't in the schema, we store an entry here from the yet-unused * spec alias to the name-in-schema. This lets us easily lookup those elements * in `this.byImportName` if that spec alias ends up getting used later and * we need to generate an error message. (You might think we only need to * remember one example for error messages, but because we can remove features * we need to remember all of them.) */ private readonly conflictsByAlias: SetMultiMap = new SetMultiMap(); constructor(readonly coreItself: CoreFeature) { this.add(coreItself); const coreDef = findCoreSpecVersion(coreItself.url); if (!coreDef) { throw ERRORS.UNKNOWN_LINK_VERSION.err(`Schema uses unknown version ${coreItself.url.version} of the ${coreItself.url.name} spec`); } this.coreDefinition = coreDef; } getByIdentity(identity: string): CoreFeature | undefined { return this.byIdentity.get(identity)?.[0]; } allFeatures(): CoreFeature[] { return [...this.byIdentity.values()].map(([feature]) => feature); } private removeFeature(featureIdentity: string) { const entry = this.byIdentity.get(featureIdentity); if (entry) { const [feature] = entry; this.byIdentity.delete(featureIdentity); const alias = feature.nameInSchema; this.byAlias.delete(alias); for (const { name: importInSpec, as } of feature.imports) { const importInSchema = as ?? importInSpec; const isDirective = importInSpec.charAt(0) === "@"; const nameInSchema = isDirective ? importInSchema.slice(1) : importInSchema; this.byImportName.delete(importInSchema); const split = CoreFeatures.splitPrefixedName(nameInSchema); if (!split) { continue; } const [splitAlias] = split; if (splitAlias === alias) { continue; } let conflicts = this.conflictsByAlias.get(importInSchema); if (!conflicts) { continue; } conflicts.delete(importInSchema); if (conflicts.size) { continue; } this.conflictsByAlias.delete(importInSchema); } } } private maybeAddFeature(directive: Directive): CoreFeature | undefined { if (directive.definition?.name !== this.coreItself.nameInSchema) { return undefined; } const typedDirective = directive as Directive const args = typedDirective.arguments(); const url = this.coreDefinition.extractFeatureUrl(args); const imports = extractCoreFeatureImports(url, typedDirective); const feature = new CoreFeature(url, args.as ?? url.name, directive, imports, args.for); this.add(feature); directive.schema().blueprint.onAddedCoreFeature(directive.schema(), feature); return feature; } private add(feature: CoreFeature) { const identity = feature.url.identity; // The identity can't already be mapped to another @link/CoreFeature. (Even // when they're different major versions, they're usually describing the // same capabilities but in incompatible ways, so we don't want to allow // the same schema to try to use multiple of them.) if (this.byIdentity.has(identity)) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" since it has already been linked in the schema.`, ); } const alias = feature.nameInSchema; // Normally we'd always forbid "__" in aliases. However, there are some // older supergraph schemas that link the "tag" and "inaccessible" specs to // the aliases "federation__tag" and "federation__inaccessible". This is // due to bugs in older versions of composition, but is technically fine // since these specs have no types and directives other than the default // directive, so they never prefix anything with "__". So we make a very // specific exception here for that case. We may remove this exception in // the future, once support has been dropped for those bugged composition // versions. if ( !(identity === tagIdentity && alias === 'federation__tag' && feature.imports.length === 0) && !(identity === inaccessibleIdentity && alias === 'federation__inaccessible' && feature.imports.length === 0) ) { // Don't allow spec name-in-schemas/aliases to have "__" in them, as // namespace splitting splits on the earliest "__" (so a namespaced name // with an alias containing "__" would be erroneously split mid-alias). if (alias.indexOf('__') !== -1) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" as "${alias}" since it contains "__". Please rename to a compliant name via "as".`, ); } } // Don't allow spec name-in-schemas/aliases to end in "_", as namespace // splitting splits on the earliest "__" (so a namespaced name with an alias // ending with "_" would end up with "___", and be split before the ending // "_" instead of after). if (alias.charAt(alias.length - 1) === '_') { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" as "${alias}" since it ends in "_". Please rename to a compliant name via "as".`, ); } // Ideally here, we wouldn't allow spec name-in-schemas/aliases to not be // valid GraphQL names. However, enough supergraph schemas use "." and "-" // after the first character that we can't impose that validation now. So // instead, we match using a slightly relaxed regex than allows "." and "-" // after the first character. For schemas that have "." or "-", they won't // be able to use namespaced names for their spec schema elements due to // GraphQL validation, but imports will still work. // // Note the error message below purposely says "not a valid GraphQL name" // because we want to encourage users to actually use GraphQL names and // avoid creating more exceptional cases. if (!aliasRegexp.test(alias)) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature "${identity}" as "${alias}" since it is not a valid GraphQL name. Please rename to a compliant name via "as".`, ); } // Don't allow spec name-in-schemas/aliases to conflict with previous // imports using "__" with that alias. const conflicts = this.conflictsByAlias.get(alias); if (conflicts) { const importInSchema = conflicts?.values()?.next()?.value; assert(importInSchema !== undefined, `Unexpectedly empty conflicts set`); const entry = this.byImportName.get(importInSchema); assert(entry, `Unexpectedly cannot find feature for import`); const [conflictFeature, importInSpec] = entry; const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${conflictIdentity}" since it can be confused with a namespaced name from another linked feature "${identity}". Please rename the import or feature to avoid conflicts via "as".`, ); } // Don't allow spec name-in-schemas/aliases to have default directive names // that conflict with previous imports. const importInSchema = "@" + alias; const entry = this.byImportName.get(importInSchema); if (entry) { const [conflictFeature, importInSpec] = entry; const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${conflictIdentity}" since it can be confused with a namespaced name from another linked feature "${identity}". Please rename the import or feature to avoid conflicts via "as".`, ); } // The alias can't be already mapped to another @link/CoreFeature. const existingFeature = this.byAlias.get(alias); if (existingFeature !== undefined) { const existingIdentity = existingFeature.url.identity; this.checkTagInaccessibleConflict(existingIdentity, identity); throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot link feature ${identity} as "${alias}" since another feature "${existingIdentity}" already uses that alias. Please rename the feature to avoid conflicts via "as".`, ); } const importsMap: Map = new Map(); for (const { name: importInSpec, as } of feature.imports) { const importInSchema = as ?? importInSpec; const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; const isDirective = importInSpec.charAt(0) === "@"; const nameInSpec = isDirective ? importInSpec.slice(1) : importInSpec; const nameInSchema = isDirective ? importInSchema.slice(1) : importInSchema; // Only allow mapping to a name with "__" if it's a no-op import or if // it uses a non-existent spec alias. const split = CoreFeatures.splitPrefixedName(nameInSchema); if (split) { const [splitAlias, splitNameInSpec] = split; if (splitAlias === alias) { if (splitNameInSpec !== nameInSpec) { const splitImportInSpec = isDirective ? "@" + splitNameInSpec : splitNameInSpec; throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it can be confused with the namespaced name for "${splitImportInSpec}". Please rename the import to avoid conflicts via "as".`, ); } } else { const conflictFeature = this.byAlias.get(splitAlias); if (conflictFeature) { const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it can be confused with a namespaced name from another linked feature "${conflictIdentity}". Please rename the import or feature to avoid conflicts via "as".`, ); } else { // As mentioned in the docs for `this.conflictsByAlias`, we have to // record the import in case a feature gets added with the spec // alias later. this.conflictsByAlias.add(splitAlias, importInSchema); } } } // For default directives, only allow mapping to a spec alias if it's a // no-op import. if (isDirective) { if (nameInSchema === alias) { if (nameInSpec !== feature.url.name) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it can be confused with the namespaced name for "@${feature.url.name}". Please rename the import to avoid conflicts via "as".`, ); } } else { const conflictFeature = this.byAlias.get(nameInSchema); if (conflictFeature) { const conflictIdentity = conflictFeature.url.identity; this.checkTagInaccessibleConflict(conflictIdentity, identity); throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it can be confused with a namespaced name from another linked feature "${conflictIdentity}". Please rename the import or feature to avoid conflicts via "as".`, ); } } } // The name-in-spec can't be already mapped to a different name-in-schema. const existingImportInSchema = importsMap.get(importInSpec); if (existingImportInSchema === undefined) { importsMap.set(importInSpec, importInSchema); } else { if (existingImportInSchema !== importInSchema) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it was previously imported as "${existingImportInSchema}". Please remove one of these imports.`, ); } } // The name-in-schema can't already be mapped to a different name-in-spec. const entry = this.byImportName.get(importInSchema); if (entry === undefined) { this.byImportName.set(importInSchema, [feature, importInSpec]); } else { const [existingFeature, existingImportInSpec] = entry; const existingIdentity = existingFeature.url.identity; if (existingIdentity !== identity) { this.checkTagInaccessibleConflict(existingIdentity, identity); throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it was previously imported from feature "${existingIdentity}". Please rename the import to avoid conflicts via "as".`, ); } if (existingImportInSpec !== importInSpec) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${identity}" since it was previously imported for "${existingImportInSpec}". Please rename the import to avoid conflicts via "as".`, ); } } } this.byAlias.set(alias, feature); this.byIdentity.set(identity, [feature, importsMap]); } /** * Returns whether the spec alias would pass the checks in `add()`, except * import conflicts are taken from the given map, which should be computed via * `computeAliasConflicts()`. */ isAliasValid( alias: string, identity: string, importConflictsByIdentity: ImportConflictsByIdentity, ) { // Don't allow aliases to have "__" in them. Note that this method is only // used in merging to detect whether we need to rename the spec, so we don't // need the exception for "federation__tag" and "federation__inaccessible" // here. if (alias.indexOf('__') !== -1) { return false; } // Don't allow aliases to end in "_". if (alias.charAt(alias.length - 1) === '_') { return false; } // Don't allow aliases to not be valid GraphQL names. Note that unlike // `add()`, we consider "." and "-" to not be valid here, but since this // method is only used in merging to detect whether we need to rename the // spec, this has the effect of ensuring that supergraph schemas don't use // "." and "-" in their alias (which will help later if we want to fully // forbid "." and "-" in aliases). if (!nameRegexp.test(alias)) { return false; } for (const [otherIdentity, importConflicts] of importConflictsByIdentity.entries()) { if (identity === otherIdentity) { // For import names namespaced using this alias, only allow them if // their no-op imports. if (importConflicts.self.has(alias)) { return false; } } else { // Don't allow imports of other specs that are namespaced by this alias. if (importConflicts.other.has(alias)) { return false; } } } // The alias can't be already mapped to another @link/CoreFeature. if (this.byAlias.has(alias)) { return false; } return true; } /** * This is a method that helps us handle the case where: * 1. directives of some spec are being composed into the supergraph (due to * them being Apollo specs or via `@composeDirective`), * 2. those directives don't actually have any conflicts, and * 3. the spec itself has alias conflicts when linked with the spec's name. * * For the `@composeDirective` case at least, you might think we could just * use the `@link(as:)` rename from the subgraph, but when we established * `@composeDirective` we never mandated that the spec aliases be the same * (just their composed directive name-in-schemas). The reason we focused on * directives was that downstream consumers were expecting certain directive * names, so it makes sense to force agreement on a single name per directive * across subgraphs. As part of that, we explicitly generate imports for all * those directives, so the spec alias is never used for namespaced names via * "__", and consumers consequently don't deal or care about those aliases * much. We could make a breaking change to force alignment on a spec alias to * use in the supergraph, but alias agreement likely isn't valuable enough for * a breakage. More importantly, it also doesn't really solve the case for * conflicts linking Apollo specs. * * So instead, when we detect a conflict, we generate a unique alias. This * method does two things: * 1. Outputs data that can be used to efficiently detect import conflicts. * 2. Outputs a function that can be used to generate unique aliases. * * For unique alias computation, we compute a non-conflicting prefix by using * a trie to determine a GraphQL name that isn't a prefix of any existing * names (this prefix also doesn't use "_" outside the first character, so it * should be safe for aliases). We then add an incrementing index to it, to * account for core features that have the same spec name. Finally, we add * the spec name but without any non-letter characters. */ static computeAliasConflicts( specAliases: { url: FeatureUrl, alias: string, imports: CoreImport[], }[], elementNames: Set, ): { importConflictsByIdentity: ImportConflictsByIdentity, computeUniqueAlias: (specName: string) => string, } { // Generate `importConflictsByIdentity` and track names for the trie. const trieNames = elementNames; const importConflictsByIdentity: ImportConflictsByIdentity = new Map(); for (const { url, alias, imports } of specAliases) { trieNames.add(alias); const self = new Set(); const other = new Set(); for (const { name: importInSpec, as } of imports) { const importInSchema = as ?? importInSpec; const isDirective = importInSpec.charAt(0) === "@"; const nameInSpec = isDirective ? importInSpec.slice(1) : importInSpec; const nameInSchema = isDirective ? importInSchema.slice(1) : importInSchema; trieNames.add(nameInSchema); const split = CoreFeatures.splitPrefixedName(nameInSchema); if (split) { const [splitAlias, splitNameInSpec] = split; if (splitNameInSpec !== nameInSpec) { // Alias being `splitAlias` would generate a conflict due to the // import not being a no-op import for a namespaced name. self.add(splitAlias); } // Alias being `splitAlias` would generate a conflict due to this // import from some other identity being namespaced to it. other.add(splitAlias); } if (isDirective) { // Alias being 'nameInSchema' would generate a conflict due to the // import not being a no-op import for the default directive. if (nameInSpec !== url.name) { self.add(nameInSchema); } // Alias being `nameInSchema` would generate a conflict due to this // import from some other identity being the default directive for it. other.add(nameInSchema) } } importConflictsByIdentity.set(url.identity, { self, other }); } // Create the closure for computing unique aliases via a trie. let prefix: string | null = null; let index: number = 0; let computeUniqueAlias = (specName: string): string => { if (prefix === null) { const aliasStart = [ '_', 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ].join(''); const aliasContinue = [ 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', '0123456789', ].join(''); type TrieNode = { children: Map; parent: TrieNode | null; char: string; }; const root: TrieNode = { children: new Map(), parent: null, char: '' }; // Populate the trie. for (const name of trieNames) { let node = root; for (const char of name) { let child = node.children.get(char); if (!child) { child = { children: new Map(), parent: node, char }; node.children.set(char, child); } node = child; } } // Note that we never really remove elements from this queue, we just // advance the index pointing to the head of the queue. This is fine // since its size is bounded above by the number of nodes in trie. const queue: TrieNode[] = [root]; let head = 0; while (prefix === null) { const possibleChars = head === 0 ? aliasStart : aliasContinue; const node = queue[head++]; for (const char of possibleChars) { const child = node.children.get(char); if (child) { queue.push(child); } else { const chars = [char]; for (let cur: TrieNode | null = node; cur?.parent; cur = cur.parent) { chars.push(cur.char); } prefix = chars.reverse().join(''); break; } } } } const suffix = specName.replace(/[^a-zA-Z]/g, ''); return `${prefix}${index++}${suffix}`; } return { importConflictsByIdentity, computeUniqueAlias, }; } /** * There's a particular pattern in Fed 1 subgraphs, where they would try to * link the "tag" or "inaccessible" specs directly instead of importing the * directives from the "federation" spec, and this can cause a conflict. This * function gives a more helpful error message in that case. * * To elaborate, those are supergraph specs, not subgraph ones, and subgraph * code doesn't check for the supergraph spec (just the "federation" spec). It * may have worked before because the name we happened to import using the * "federation" spec was the same, but if they become unaligned in the future * (e.g. due to either our code or their schema using "as"), we'd suddenly * start silently ignoring those spec directive applications. */ private checkTagInaccessibleConflict(identity1: string, identity2: string) { // TODO: We can't import this from "./specs/federationSpec" because it // causes a circular import loop; we should fix that later. const federationIdentity = 'https://specs.apollo.dev/federation'; const identities = new Set([identity1, identity2]); if (!identities.has(federationIdentity)) { return; } const [directive, identity] = identities.has(tagIdentity) ? ['tag', tagIdentity] : identities.has(inaccessibleIdentity) ? ['inaccessible', inaccessibleIdentity] : [undefined, undefined]; if (directive && identity) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Please import "@${directive}" from the feature "${federationIdentity}" instead of using "${identity}" to avoid potential unexpected behavior in the future.`, ); } } /** * If the given schema element belongs to a spec/feature, return that feature * along with the name-in-spec and whether it was imported. Note that if the * element uses a default name but its name-in-spec was imported already under * a different name (a.k.a. a shadowing import), this method will still * consider it to belong to that feature, but its name-in-spec will be null. */ sourceFeature(element: DirectiveDefinition | Directive | NamedType): | { feature: CoreFeature, nameInFeature: string | null, isImported: boolean, } | undefined { const isDirective = element instanceof DirectiveDefinition || element instanceof Directive; // Validations guarantee that import names don't collide with the default // names of different spec schema elements, so it doesn't technically matter // which order we check first. But we do have to some extra work for // shadowing imports if we don't check imports first, so we do that first. const importName = isDirective ? '@' + element.name : element.name; const entry = this.byImportName.get(importName); if (entry) { const [feature, importInSpec] = entry; return { feature, nameInFeature: isDirective ? importInSpec.slice(1) : importInSpec, isImported: true, }; } // If it's not an import, check whether it's a default name with no // shadowing imports. const defaultEntry = this.sourceDefaultName(isDirective, element.name); if (!defaultEntry) { return undefined; } const [feature, nameInSpec] = defaultEntry; const importInSpec = isDirective ? '@' + nameInSpec : nameInSpec; // Note that if the import name is the same as the element's name, it's not // a shadowing import, and we should return a non-null `nameInFeature`. But // if that were true, we would have found an entry in `this.byImportName` // above when checking for imports. So we don't need to handle that case // specially here. return { feature, nameInFeature: this.getImportName(feature, importInSpec) === undefined ? nameInSpec : null, isImported: false, }; } /** * Assuming the core features are for the given schema, returns an error for * each schema element with a shadowing import. A "shadowing import" occurs * when an element would normally belong to a feature due to having a default * name for it, but the name-in-spec has been imported already under a * different name. Note that for backwards-compatibility reasons, we ignore * shadowed types if they're only used by other shadowed elements. * * We enforce this validation because downstream code almost always assumes * there's exactly one name for a spec element, and allowing multiple elements * with the same feature and name-in-spec will thus result in some of those * elements being erroneously ignored. (This is similar to the validation that * forbids importing the same name-in-spec with different name-in-schemas, but * that can't be easily done when adding a feature since it depends on what * elements are actually in the schema, and that doesn't get finalized until * later in the schema-building process.) */ validateNoShadowingImports(schema: Schema): GraphQLError[] { const errors: GraphQLError[] = []; for (const element of [...schema.allTypes(), ...schema.allDirectives()]) { const shadowingImport = this.getShadowingImport(element); if (!shadowingImport) { continue; } const isUsed = element instanceof DirectiveDefinition ? element.applications().size !== 0 : this.getReferencingRootElements(element) .some((referencer) => { return referencer.kind === 'SchemaDefinition' ? true : !this.getShadowingImport(referencer) }); if (!isUsed) { continue; } const { feature, importInSpec, importInSchema } = shadowingImport; const importInErrorMessage = importInSchema !== importInSpec ? `"${importInSpec}" as "${importInSchema}"` : `"${importInSpec}"`; errors.push(ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err( `Cannot import ${importInErrorMessage} from feature "${feature.url.identity}" since there's a used definition for the namespaced name "${element.coordinate}". Please switch usages of the namespaced name to the import name and remove the definition.`, )); } return errors; } private getShadowingImport( element: DirectiveDefinition | Directive | NamedType ): | { feature: CoreFeature, importInSpec: string, importInSchema: string, } | undefined { const isDirective = element instanceof DirectiveDefinition || element instanceof Directive; const defaultEntry = this.sourceDefaultName(isDirective, element.name); if (!defaultEntry) { return undefined; } const importName = isDirective ? '@' + element.name : element.name; const [feature, nameInSpec] = defaultEntry; const importInSpec = isDirective ? '@' + nameInSpec : nameInSpec; const importInSchema = this.getImportName(feature, importInSpec); return importInSchema !== undefined && importInSchema !== importName ? { feature, importInSpec, importInSchema, } : undefined; } /** * Returns the root schema elements (types/directives/schema definitions) that * contain references to the given type somewhere in their definition. */ getReferencingRootElements( element: NamedType, ): (DirectiveDefinition | NamedType | SchemaDefinition)[] { const referencers: (DirectiveDefinition | NamedType | SchemaDefinition)[] = []; for (const referencer of element.referencers()) { switch (referencer.kind) { case 'ObjectType': referencers.push(referencer); break; case 'InterfaceType': referencers.push(referencer); break; case 'UnionType': referencers.push(referencer); break; case 'SchemaDefinition': referencers.push(referencer); break; case 'FieldDefinition': referencers.push(referencer.parent); break; case 'InputFieldDefinition': referencers.push(referencer.parent); break; case 'ArgumentDefinition': const parent: DirectiveDefinition | FieldDefinition = referencer.parent; switch (parent.kind) { case 'DirectiveDefinition': referencers.push(parent); break; case 'FieldDefinition': referencers.push(parent.parent); break; default: assertUnreachable(parent); } break; default: assertUnreachable(referencer); } } return referencers; } /** * Returns the import name for the given feature and import-in-spec name. (By * "import", we mean the element name, prefixed with "@" if it's a directive.) */ private getImportName( feature: CoreFeature, importInSpec: string, ): string | undefined { return this.byIdentity.get(feature.url.identity)?.[1]?.get(importInSpec) } /** * If the give element name is a default name (i.e., it's prefixed with an * existing alias, or is a directive name for an existing alias), then return * the feature for that alias along with the name-in-spec for the element. */ private sourceDefaultName( isDirective: boolean, name: string, ): [CoreFeature, string] | undefined { // Handle the alias-prefixed case first. const split = CoreFeatures.splitPrefixedName(name); if (split) { const [alias, nameInSpec] = split; const feature = this.byAlias.get(alias); // Note that we explicitly do not return `undefined` here if `feature` // isn't found, and instead fall-through to the default directive name // logic below. Normally that default directive name logic would also // return `undefined`, since validations above guarantee "__" isn't in // alias names. But as noted above, we make an exception for the "tag" // and "inaccessible" specs for backwards-compatibility reasons, so we // fall-through to allow those exceptions to be found in `this.byAlias`. if (feature) { return [feature, nameInSpec]; } } // If not prefixed, then check whether it's the default directive name for // a spec. if (!isDirective) { return undefined; } const feature = this.byAlias.get(name); return feature ? [feature, feature.url.name] : undefined; } /** * Splits alias-prefixed names into their spec alias and their name-in-spec. */ private static splitPrefixedName(name: string): [string, string] | undefined { const splitIndex = name.indexOf('__'); return splitIndex !== -1 ? [name.slice(0, splitIndex), name.slice(splitIndex + 2)] : undefined; } } const graphQLBuiltInTypes: readonly string[] = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; const graphQLBuiltInTypesSpecifications: readonly TypeSpecification[] = graphQLBuiltInTypes.map((name) => createScalarTypeSpecification({ name })); const graphQLBuiltInDirectivesSpecifications: readonly DirectiveSpecification[] = [ createDirectiveSpecification({ name: 'include', locations: [DirectiveLocation.FIELD, DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], args: [{ name: 'if', type: (schema) => new NonNullType(schema.booleanType()) }], }), createDirectiveSpecification({ name: 'skip', locations: [DirectiveLocation.FIELD, DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], args: [{ name: 'if', type: (schema) => new NonNullType(schema.booleanType()) }], }), createDirectiveSpecification({ name: 'deprecated', locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.ENUM_VALUE, DirectiveLocation.ARGUMENT_DEFINITION, DirectiveLocation.INPUT_FIELD_DEFINITION], args: [{ name: 'reason', type: (schema) => schema.stringType(), defaultValue: 'No longer supported' }], }), createDirectiveSpecification({ name: 'specifiedBy', locations: [DirectiveLocation.SCALAR], args: [{ name: 'url', type: (schema) => new NonNullType(schema.stringType()) }], }), // Note that @defer and @stream are unconditionally added to `Schema` even if they are technically "optional" built-in. _But_, // the `Schema#toGraphQLJSSchema` method has an option to decide if @defer/@stream should be included or not in the resulting // schema, which is how the gateway and router can, at runtime, decide to include or not include them based on actual support. createDirectiveSpecification({ name: 'defer', locations: [DirectiveLocation.FRAGMENT_SPREAD, DirectiveLocation.INLINE_FRAGMENT], args: [ { name: 'label', type: (schema) => schema.stringType() }, { name: 'if', type: (schema) => new NonNullType(schema.booleanType()), defaultValue: true }, ], }), // Adding @stream too so that it's know and we don't error out if it is queries. It feels like it would be weird to do so for @stream but not // @defer when both are defined in the same spec. That said, that does *not* mean we currently _implement_ @stream, we don't, and so putting // it in a query will be a no-op at the moment (which technically is valid according to the spec so ...). createDirectiveSpecification({ name: 'stream', locations: [DirectiveLocation.FIELD], args: [ { name: 'label', type: (schema) => schema.stringType() }, { name: 'initialCount', type: (schema) => schema.intType(), defaultValue: 0 }, { name: 'if', type: (schema) => new NonNullType(schema.booleanType()), defaultValue: true }, ], }), ]; export type DeferDirectiveArgs = { label?: string, if?: boolean | Variable, } export type StreamDirectiveArgs = { label?: string, initialCount: number, if?: boolean, } // A valid alias. Almost a valid GraphQL name, but we need to allow "." and "-" // after the first character for supergraph schema backwards compatibility. const aliasRegexp = /^[_A-Za-z][_0-9A-Za-z.-]*$/; // A valid GraphQL name. const nameRegexp = /^[_A-Za-z][_0-9A-Za-z]*$/; // A coordinate is up to 3 GraphQL names ([_A-Za-z][_0-9A-Za-z]*). const coordinateRegexp = /^@?[_A-Za-z][_0-9A-Za-z]*(\.[_A-Za-z][_0-9A-Za-z]*)?(\([_A-Za-z][_0-9A-Za-z]*:\))?$/; export type SchemaConfig = { cacheAST?: boolean, } export class Schema { private _schemaDefinition: SchemaDefinition; private readonly _builtInTypes = new MapWithCachedArrays(); private readonly _types = new MapWithCachedArrays(); private readonly _builtInDirectives = new MapWithCachedArrays(); private readonly _directives = new MapWithCachedArrays(); private _coreFeatures?: CoreFeatures; private isConstructed: boolean = false; public isValidated: boolean = false; private cachedDocument?: DocumentNode; private apiSchema?: Schema; constructor( readonly blueprint: SchemaBlueprint = defaultSchemaBlueprint, readonly config: SchemaConfig = {}, ) { this._schemaDefinition = new SchemaDefinition(); Element.prototype['setParent'].call(this._schemaDefinition, this); graphQLBuiltInTypesSpecifications.forEach((spec) => spec.checkOrAdd(this, undefined, true)); graphQLBuiltInDirectivesSpecifications.forEach((spec) => spec.checkOrAdd(this, undefined, true)); blueprint.onConstructed(this); this.isConstructed = true; } private canModifyBuiltIn(): boolean { return !this.isConstructed; } private runWithBuiltInModificationAllowed(fct: () => void) { const wasConstructed = this.isConstructed; this.isConstructed = false; fct(); this.isConstructed = wasConstructed; } private renameTypeInternal(oldName: string, newName: string) { this._types.set(newName, this._types.get(oldName)!); this._types.delete(oldName); } private removeTypeInternal(type: BaseNamedType) { this._types.delete(type.name); } private removeDirectiveInternal(definition: DirectiveDefinition) { this._directives.delete(definition.name); } private markAsCoreSchema(coreItself: CoreFeature) { this._coreFeatures = new CoreFeatures(coreItself); } private unmarkAsCoreSchema() { this._coreFeatures = undefined; } private onModification() { // The only stuffs that are added while !isConstructed are built-in, and those shouldn't invalidate everything. if (this.isConstructed) { this.invalidate(); this.cachedDocument = undefined; this.apiSchema = undefined; } } isCoreSchema(): boolean { return this.coreFeatures !== undefined; } get coreFeatures(): CoreFeatures | undefined { return this._coreFeatures; } toAST(): DocumentNode { if (!this.cachedDocument) { // As we're not building the document from a file, having locations info might be more confusing that not. const ast = parse(printSchema(this), { noLocation: true }); const shouldCache = this.config.cacheAST ?? false; if (!shouldCache) { return ast; } this.cachedDocument = ast; } return this.cachedDocument!; } toAPISchema(): Schema { if (!this.apiSchema) { this.validate(); const apiSchema = this.clone(undefined, false); // As we compute the API schema of a supergraph, we want to ignore explicit definitions of `@defer` and `@stream` because // those correspond to the merging of potential definitions from the subgraphs, but whether the supergraph API schema // supports defer or not is unrelated to the subgraph capacity. As far as gateway/router support goes, whether the defer/stream // definitions end up being provided or not will depend on the runtime `config` argument of the `toGraphQLJSSchema` that // is the called on the API schema (the schema resulting from that method). for (const toRemoveIfCustom of ['defer', 'stream']) { const directive = apiSchema.directive(toRemoveIfCustom); if (directive && !directive.isBuiltIn) { directive.removeRecursive(); } } removeInaccessibleElements(apiSchema); removeAllCoreFeatures(apiSchema); assert(!apiSchema.isCoreSchema(), "The API schema shouldn't be a core schema") apiSchema.validate(); this.apiSchema = apiSchema; } return this.apiSchema; } private emptyASTDefinitionsForExtensionsWithoutDefinition(): DefinitionNode[] { const nodes = []; if (this.schemaDefinition.hasExtensionElements() && !this.schemaDefinition.hasNonExtensionElements()) { const node: SchemaDefinitionNode = { kind: Kind.SCHEMA_DEFINITION, operationTypes: [] }; nodes.push(node); } for (const type of this.types()) { if (type.hasExtensionElements() && !type.hasNonExtensionElements()) { const node: TypeDefinitionNode = { kind: type.astDefinitionKind, name: { kind: Kind.NAME, value: type.name }, }; nodes.push(node); } } return nodes; } toGraphQLJSSchema(config?: { includeDefer?: boolean, includeStream?: boolean }): GraphQLSchema { const includeDefer = config?.includeDefer ?? false; const includeStream = config?.includeStream ?? false; let ast = this.toAST(); // Note that AST generated by `this.toAST()` may not be fully graphQL valid because, in federation subgraphs, we accept // extensions that have no corresponding definitions. This won't fly however if we try to build a `GraphQLSchema`, so // we need to "fix" that problem. For that, we add empty definitions for every element that has extensions without // definitions (which is also what `fed1` was effectively doing). const additionalNodes = this.emptyASTDefinitionsForExtensionsWithoutDefinition(); if (includeDefer) { additionalNodes.push(this.deferDirective().toAST()); } if (includeStream) { additionalNodes.push(this.streamDirective().toAST()); } if (additionalNodes.length > 0) { ast = { kind: Kind.DOCUMENT, definitions: ast.definitions.concat(additionalNodes), } } const graphQLSchema = buildGraphqlSchemaFromAST(ast); if (additionalNodes.length > 0) { // As mentionned, if we have extensions without definition, we _have_ to add an empty definition to be able to // build a `GraphQLSchema` object. But that also mean that we lose the information doing so, as we cannot // distinguish anymore that we have no definition. A method like `graphQLSchemaToAST` for instance, would // include a definition in particular, and that could a bit surprised (and could lead to an hard-to-find bug // in the worst case if you were expecting it that something like `graphQLSchemaToAST(buildSchema(defs).toGraphQLJSSchema())` // gives you back the original `defs`). // So to avoid this, we manually delete the definition `astNode` post-construction on the created schema if // we had not definition. This should break users of the resulting schema since `astNode` is allowed to be `undefined`, // but it allows `graphQLSchemaToAST` to make the proper distinction in general. for (const node of additionalNodes) { switch (node.kind) { case Kind.SCHEMA_DEFINITION: graphQLSchema.astNode = undefined; break; case Kind.SCALAR_TYPE_DEFINITION: case Kind.OBJECT_TYPE_DEFINITION: case Kind.INTERFACE_TYPE_DEFINITION: case Kind.ENUM_TYPE_DEFINITION: case Kind.UNION_TYPE_DEFINITION: case Kind.INPUT_OBJECT_TYPE_DEFINITION: const type = graphQLSchema.getType(node.name.value); if (type) { type.astNode = undefined; } } } } return graphQLSchema; } get schemaDefinition(): SchemaDefinition { return this._schemaDefinition; } /** * All the types defined on this schema, excluding the built-in types. */ types(): readonly NamedType[] { return this._types.values(); } interfaceTypes(): readonly InterfaceType[] { return filterTypesOfKind(this.types(), 'InterfaceType'); } objectTypes(): readonly ObjectType[] { return filterTypesOfKind(this.types(), 'ObjectType'); } unionTypes(): readonly UnionType[] { return filterTypesOfKind(this.types(), 'UnionType'); } scalarTypes(): readonly ScalarType[] { return filterTypesOfKind(this.types(), 'ScalarType'); } inputTypes(): readonly InputObjectType[] { return filterTypesOfKind(this.types(), 'InputObjectType'); } enumTypes(): readonly EnumType[] { return filterTypesOfKind(this.types(), 'EnumType'); } /** * All the built-in types for this schema (those that are not displayed when printing the schema). */ builtInTypes(includeShadowed: boolean = false): readonly NamedType[] { const allBuiltIns = this._builtInTypes.values(); return includeShadowed ? allBuiltIns : allBuiltIns.filter(t => !this.isShadowedBuiltInType(t)); } private isShadowedBuiltInType(type: NamedType) { return type.isBuiltIn && this._types.has(type.name); } /** * All the types, including the built-in ones. */ allTypes(): readonly NamedType[] { return this.builtInTypes().concat(this.types()); } /** * The type of the provide name in this schema if one is defined or if it is the name of a built-in. */ type(name: string): NamedType | undefined { const type = this._types.get(name); return type ? type : this._builtInTypes.get(name); } typeOfKind(name: string, kind: T['kind']): T | undefined { const type = this.type(name); return type && type.kind === kind ? type as T : undefined; } intType(): ScalarType { return this._builtInTypes.get('Int')! as ScalarType; } floatType(): ScalarType { return this._builtInTypes.get('Float')! as ScalarType; } stringType(): ScalarType { return this._builtInTypes.get('String')! as ScalarType; } booleanType(): ScalarType { return this._builtInTypes.get('Boolean')! as ScalarType; } idType(): ScalarType { return this._builtInTypes.get('ID')! as ScalarType; } builtInScalarTypes(): ScalarType[] { return [ this.intType(), this.floatType(), this.stringType(), this.booleanType(), this.idType(), ]; } addType(type: T): T { const existing = this.type(type.name); if (existing) { // Like for directive, we let user shadow built-in types, but the definition must be valid. assert(existing.isBuiltIn, () => `Type ${type} already exists in this schema`); } if (type.isAttached()) { // For convenience, let's not error out on adding an already added type. assert(type.parent == this, () => `Cannot add type ${type} to this schema; it is already attached to another schema`); return type; } if (type.isBuiltIn) { assert(!this.isConstructed, `Cannot add built-in ${type} to this schema (built-ins can only be added at schema construction time)`); this._builtInTypes.set(type.name, type); } else { this._types.set(type.name, type); } Element.prototype['setParent'].call(type, this); // If a type is the default name of a root, it "becomes" that root automatically, // unless some other root has already been set. const defaultSchemaRoot = checkDefaultSchemaRoot(type); if (defaultSchemaRoot && !this.schemaDefinition.root(defaultSchemaRoot)) { // Note that checkDefaultSchemaRoot guarantees us type is an ObjectType this.schemaDefinition.setRoot(defaultSchemaRoot, type as ObjectType); } this.onModification(); return type; } /** * All the directive defined on this schema, excluding the built-in directives. */ directives(): readonly DirectiveDefinition[] { return this._directives.values(); } /** * All the built-in directives for this schema (those that are not displayed when printing the schema). */ builtInDirectives(includeShadowed: boolean = false): readonly DirectiveDefinition[] { return includeShadowed ? this._builtInDirectives.values() : this._builtInDirectives.values().filter(d => !this.isShadowedBuiltInDirective(d)); } allDirectives(): readonly DirectiveDefinition[] { return this.builtInDirectives().concat(this.directives()); } private isShadowedBuiltInDirective(directive: DirectiveDefinition) { return directive.isBuiltIn && this._directives.has(directive.name); } directive(name: string): DirectiveDefinition | undefined { const directive = this._directives.get(name); return directive ? directive : this.builtInDirective(name); } builtInDirective(name: string): DirectiveDefinition | undefined { return this._builtInDirectives.get(name); } *allNamedSchemaElement(): Generator, void, undefined> { for (const type of this.types()) { yield type; yield* type.allChildElements(); } for (const directive of this.directives()) { yield directive; yield* directive.arguments(); } } *allSchemaElement(): Generator, void, undefined> { yield this._schemaDefinition; yield* this.allNamedSchemaElement(); } addDirectiveDefinition(name: string): DirectiveDefinition; addDirectiveDefinition(directive: DirectiveDefinition): DirectiveDefinition; addDirectiveDefinition(directiveOrName: string | DirectiveDefinition): DirectiveDefinition { const definition = typeof directiveOrName === 'string' ? new DirectiveDefinition(directiveOrName) : directiveOrName; const existing = this.directive(definition.name); // Note that we allow the schema to define a built-in manually (and the manual definition will shadow the // built-in one). It's just that validation will ensure the definition ends up the one expected. assert(!existing || existing.isBuiltIn, () => `Directive ${definition} already exists in this schema`); if (definition.isAttached()) { // For convenience, let's not error out on adding an already added directive. assert(definition.parent == this, () => `Cannot add directive ${definition} to this schema; it is already attached to another schema`); return definition; } if (definition.isBuiltIn) { assert(!this.isConstructed, () => `Cannot add built-in ${definition} to this schema (built-ins can only be added at schema construction time)`); this._builtInDirectives.set(definition.name, definition); } else { this._directives.set(definition.name, definition); } Element.prototype['setParent'].call(definition, this); this.onModification(); return definition; } invalidate() { if (this.isValidated) { this.blueprint.onInvalidation(this); } this.isValidated = false; } /** * Marks the schema as validated _without running actual validation_. * Should obviously only be called when we know the built schema must be valid. * * Note that if `validate` is called after this, then it will exit immediately without validation as * the schema will have been marked as validated. However, if this schema is further modified, then * `invalidate` will be called, after which `validate` would run validation again. */ assumeValid() { this.runWithBuiltInModificationAllowed(() => { addIntrospectionFields(this); }); this.isValidated = true; } validate() { if (this.isValidated) { return; } this.runWithBuiltInModificationAllowed(() => { addIntrospectionFields(this); }); // TODO: we check that all types are properly set (aren't undefined) in `validateSchema`, but `validateSDL` will error out beforehand. We should // probably extract that part of `validateSchema` and run `validateSDL` conditionally on that first check. let errors = validateSDL(this.toAST(), undefined, this.blueprint.validationRules()).map((e) => this.blueprint.onGraphQLJSValidationError(this, e)); errors = errors.concat(validateSchema(this)); // Core feature validations around shadowing imports are @link-specific and // don't really depend on the rest of the schema being valid, so it's fine // to include them with standard GraphQL validation errors. errors = errors.concat( this.coreFeatures?.validateNoShadowingImports(this) ?? [] ) // We avoid adding federation-specific validations if the base schema is not proper graphQL as the later can easily trigger // the former (for instance, someone mistyping the 'fields' argument name of a @key). if (errors.length === 0) { this.runWithBuiltInModificationAllowed(() => { errors = this.blueprint.onValidation(this); }); } if (errors.length > 0) { throw ErrGraphQLValidationFailed(errors as GraphQLError[]); } this.isValidated = true; } clone(builtIns?: SchemaBlueprint, cloneJoinDirectives: boolean = true): Schema { const cloned = new Schema(builtIns ?? this.blueprint); copy(this, cloned, cloneJoinDirectives); if (this.isValidated) { cloned.assumeValid(); } return cloned; } private getBuiltInDirective( name: string ): DirectiveDefinition { const directive = this.directive(name); assert(directive, `The provided schema has not be built with the ${name} directive built-in`); return directive as DirectiveDefinition; } includeDirective(): DirectiveDefinition<{if: boolean | Variable}> { return this.getBuiltInDirective('include'); } skipDirective(): DirectiveDefinition<{if: boolean | Variable}> { return this.getBuiltInDirective('skip'); } deprecatedDirective(): DirectiveDefinition<{reason?: string}> { return this.getBuiltInDirective('deprecated'); } specifiedByDirective(): DirectiveDefinition<{url: string}> { return this.getBuiltInDirective('specifiedBy'); } deferDirective(): DirectiveDefinition { return this.getBuiltInDirective('defer'); } streamDirective(): DirectiveDefinition { return this.getBuiltInDirective('stream'); } /** * Gets an element of the schema given its "schema coordinate". * * Note that the syntax for schema coordinates is the one from the upcoming GraphQL spec: https://github.com/graphql/graphql-spec/pull/794. */ elementByCoordinate(coordinate: string): NamedSchemaElement | undefined { if (!coordinate.match(coordinateRegexp)) { // To be fair, graphQL coordinate is not yet officially part of the spec but well... throw ERRORS.INVALID_GRAPHQL.err(`Invalid argument "${coordinate}: it is not a syntactically valid graphQL coordinate."`); } const argStartIdx = coordinate.indexOf('('); const start = argStartIdx < 0 ? coordinate : coordinate.slice(0, argStartIdx); // Argument syntax is `foo(argName:)`, so the arg name start after the open parenthesis and go until the final ':)'. const argName = argStartIdx < 0 ? undefined : coordinate.slice(argStartIdx + 1, coordinate.length - 2); const splittedStart = start.split('.'); const typeOrDirectiveName = splittedStart[0]; const fieldOrEnumName = splittedStart[1]; const isDirective = typeOrDirectiveName.startsWith('@'); if (isDirective) { if (fieldOrEnumName) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid argument "${coordinate}: it is not a syntactically valid graphQL coordinate."`); } const directive = this.directive(typeOrDirectiveName.slice(1)); return argName ? directive?.argument(argName) : directive; } else { const type = this.type(typeOrDirectiveName); if (!type || !fieldOrEnumName) { return type; } switch (type.kind) { case 'ObjectType': case 'InterfaceType': const field = type.field(fieldOrEnumName); return argName ? field?.argument(argName) : field; case 'InputObjectType': if (argName) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid argument "${coordinate}: it is not a syntactically valid graphQL coordinate."`); } return type.field(fieldOrEnumName); case 'EnumType': if (argName) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid argument "${coordinate}: it is not a syntactically valid graphQL coordinate."`); } return type.value(fieldOrEnumName); default: throw ERRORS.INVALID_GRAPHQL.err(`Invalid argument "${coordinate}: it is not a syntactically valid graphQL coordinate."`); } } } } export class RootType extends BaseExtensionMember { constructor(readonly rootKind: SchemaRootKind, readonly type: ObjectType) { super(); } isDefaultRootName() { return defaultRootName(this.rootKind) == this.type.name; } protected removeInner() { SchemaDefinition.prototype['removeRootType'].call(this._parent, this); } } export class SchemaDefinition extends SchemaElement { readonly kind = 'SchemaDefinition' as const; protected readonly _roots = new MapWithCachedArrays(); protected _extensions: Extension[] | undefined; public preserveEmptyDefinition: boolean = false; roots(): readonly RootType[] { return this._roots.values(); } applyDirective( nameOrDef: DirectiveDefinition | string, args?: TApplicationArgs, asFirstDirective: boolean = false, ): Directive { const applied = super.applyDirective(nameOrDef, args, asFirstDirective) as Directive; const schema = this.schema(); const coreFeatures = schema.coreFeatures; if (isCoreSpecDirectiveApplication(applied)) { if (coreFeatures) { throw ERRORS.INVALID_LINK_DIRECTIVE_USAGE.err(`Invalid duplicate application of @core/@link`); } const schemaDirective = applied as Directive; const args = schemaDirective.arguments(); const url = FeatureUrl.parse((args.url ?? args.feature)!); const imports = extractCoreFeatureImports(url, schemaDirective); const core = new CoreFeature(url, args.as ?? url.name, schemaDirective, imports, args.for); Schema.prototype['markAsCoreSchema'].call(schema, core); // We also any core features that may have been added before we saw the @link for link itself this.appliedDirectives .filter((a) => a !== applied) .forEach((other) => CoreFeatures.prototype['maybeAddFeature'].call(schema.coreFeatures, other)); } else if (coreFeatures) { CoreFeatures.prototype['maybeAddFeature'].call(coreFeatures, applied); } this.onModification(); return applied; } root(rootKind: SchemaRootKind): RootType | undefined { return this._roots.get(rootKind); } rootType(rootKind: SchemaRootKind): ObjectType | undefined { return this.root(rootKind)?.type; } setRoot(rootKind: SchemaRootKind, nameOrType: ObjectType | string): RootType { let toSet: RootType; if (typeof nameOrType === 'string') { this.checkUpdate(); const obj = this.schema().type(nameOrType); if (!obj) { throw ERRORS.INVALID_GRAPHQL.err(`Cannot set schema ${rootKind} root to unknown type ${nameOrType}`); } else if (obj.kind != 'ObjectType') { throw ERRORS.INVALID_GRAPHQL.err(`${defaultRootName(rootKind)} root type must be an Object type${rootKind === 'query' ? '' : ' if provided'}, it cannot be set to ${nameOrType} (an ${obj.kind}).`); } toSet = new RootType(rootKind, obj); } else { this.checkUpdate(nameOrType); toSet = new RootType(rootKind, nameOrType); } const prevRoot = this._roots.get(rootKind); if (prevRoot) { removeReferenceToType(this, prevRoot.type); } this._roots.set(rootKind, toSet); Element.prototype['setParent'].call(toSet, this); addReferenceToType(this, toSet.type); this.onModification(); return toSet; } extensions(): Extension[] { return this._extensions ?? []; } hasExtension(extension: Extension): boolean { return this._extensions?.includes(extension) ?? false; } newExtension(): Extension { return this.addExtension(new Extension()); } addExtension(extension: Extension): Extension { this.checkUpdate(); // Let's be nice and not complaint if we add an extension already added. if (this.hasExtension(extension)) { return extension; } assert(!extension.extendedElement, 'Cannot add extension to this schema: extension is already added to another schema'); if (this._extensions) { this._extensions.push(extension); } else { this._extensions = [extension]; } Extension.prototype['setExtendedElement'].call(extension, this); this.onModification(); return extension; } hasExtensionElements(): boolean { return !!this._extensions; } hasNonExtensionElements(): boolean { return this.preserveEmptyDefinition || this.appliedDirectives.some((d) => d.ofExtension() === undefined) || this.roots().some((r) => r.ofExtension() === undefined); } private removeRootType(rootType: RootType) { this._roots.delete(rootType.rootKind); removeReferenceToType(this, rootType.type); } protected removeTypeReference(toRemove: NamedType) { for (const rootType of this.roots()) { if (rootType.type == toRemove) { this._roots.delete(rootType.rootKind); } } } toString() { return `schema[${this._roots.keys().join(', ')}]`; } } export class ScalarType extends BaseNamedType { readonly kind = 'ScalarType' as const; readonly astDefinitionKind = Kind.SCALAR_TYPE_DEFINITION; protected removeTypeReference(type: NamedType) { assert(false, `Scalar type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } protected hasNonExtensionInnerElements(): boolean { return false; // No inner elements } protected removeInnerElementsExtensions(): void { // No inner elements } protected removeInnerElements(): void { // No inner elements } protected removeReferenceRecursive(ref: OutputTypeReferencer | InputTypeReferencer): void { ref.remove(); } } export class InterfaceImplementation extends BaseExtensionMember { readonly interface: InterfaceType // Note: typescript complains if a parameter is named 'interface'. This is why we don't just declare the `readonly interface` // field within the constructor. constructor(itf: InterfaceType) { super(); this.interface = itf; } protected removeInner() { FieldBasedType.prototype['removeInterfaceImplementation'].call(this._parent, this.interface); } toString() { return `'implements ${this.interface}'`; } } // Abstract class for ObjectType and InterfaceType as they share most of their structure. Note that UnionType also // technically has one field (__typename), but because it's only one, it is special cased and UnionType is not a // subclass of this class. abstract class FieldBasedType, R> extends BaseNamedType { // Note that we only keep one InterfaceImplementation per interface name, and so each `implements X` belong // either to the main type definition _or_ to a single extension. In theory, a document could have `implements X` // in both of those places (or on 2 distinct extensions). We don't preserve that level of detail, but this // feels like a very minor limitation with little practical impact, and it avoids additional complexity. private _interfaceImplementations: MapWithCachedArrays> | undefined; private readonly _fields: MapWithCachedArrays> = new MapWithCachedArrays(); private _cachedNonBuiltInFields?: readonly FieldDefinition[]; protected onAttached() { // Note that we can only add the __typename built-in field when we're attached, because we need access to the // schema string type. Also, we're effectively modifying a built-in (to add the type), so we // need to let the schema accept it. Schema.prototype['runWithBuiltInModificationAllowed'].call(this.schema(), () => { this.addField(new FieldDefinition(typenameFieldName, true), new NonNullType(this.schema().stringType())); }); } private removeFieldInternal(field: FieldDefinition) { this._fields.delete(field.name); this._cachedNonBuiltInFields = undefined; } interfaceImplementations(): readonly InterfaceImplementation[] { return this._interfaceImplementations?.values() ?? []; } interfaceImplementation(type: string | InterfaceType): InterfaceImplementation | undefined { return this._interfaceImplementations ? this._interfaceImplementations.get(typeof type === 'string' ? type : type.name) : undefined; } interfaces(): readonly InterfaceType[] { return this.interfaceImplementations().map(impl => impl.interface); } implementsInterface(type: string | InterfaceType): boolean { return this._interfaceImplementations?.has(typeof type === 'string' ? type : type.name) ?? false; } addImplementedInterface(nameOrItfOrItfImpl: InterfaceImplementation | InterfaceType | string): InterfaceImplementation { let toAdd: InterfaceImplementation; if (nameOrItfOrItfImpl instanceof InterfaceImplementation) { this.checkUpdate(nameOrItfOrItfImpl); toAdd = nameOrItfOrItfImpl; } else { let itf: InterfaceType; if (typeof nameOrItfOrItfImpl === 'string') { this.checkUpdate(); const maybeItf = this.schema().type(nameOrItfOrItfImpl); if (!maybeItf) { throw ERRORS.INVALID_GRAPHQL.err(`Cannot implement unknown type ${nameOrItfOrItfImpl}`); } else if (maybeItf.kind != 'InterfaceType') { throw ERRORS.INVALID_GRAPHQL.err(`Cannot implement non-interface type ${nameOrItfOrItfImpl} (of type ${maybeItf.kind})`); } itf = maybeItf; } else { itf = nameOrItfOrItfImpl; } toAdd = new InterfaceImplementation(itf); } const existing = this._interfaceImplementations?.get(toAdd.interface.name); if (!existing) { if (!this._interfaceImplementations) { this._interfaceImplementations = new MapWithCachedArrays(); } this._interfaceImplementations.set(toAdd.interface.name, toAdd); addReferenceToType(this, toAdd.interface); Element.prototype['setParent'].call(toAdd, this); this.onModification(); return toAdd; } else { return existing; } } /** * All the fields of this type, excluding the built-in ones. */ fields(): readonly FieldDefinition[] { if (!this._cachedNonBuiltInFields) { this._cachedNonBuiltInFields = this._fields.values().filter(f => !f.isBuiltIn); } return this._cachedNonBuiltInFields; } hasFields(): boolean { return this.fields().length > 0; } /** * All the built-in fields for this type (those that are not displayed when printing the schema). */ builtInFields(): FieldDefinition[] { return this.allFields().filter(f => f.isBuiltIn); } /** * All the fields of this type, including the built-in ones. */ allFields(): readonly FieldDefinition[] { return this._fields.values(); } field(name: string): FieldDefinition | undefined { return this._fields.get(name); } /** * A shortcut to access the __typename field. * * Note that an _attached_ (field-based) type will always have this field, but _detached_ types won't, so this method * will only return `undefined` on detached objects. */ typenameField(): FieldDefinition | undefined { return this.field(typenameFieldName); } addField(nameOrField: string | FieldDefinition, type?: Type): FieldDefinition { let toAdd: FieldDefinition; if (typeof nameOrField === 'string') { this.checkUpdate(); toAdd = new FieldDefinition(nameOrField); } else { this.checkUpdate(nameOrField); toAdd = nameOrField; } if (this.field(toAdd.name)) { throw ERRORS.INVALID_GRAPHQL.err(`Field ${toAdd.name} already exists on ${this}`); } if (type && !isOutputType(type)) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid input type ${type} for field ${toAdd.name}: object and interface field types should be output types.`); } this._fields.set(toAdd.name, toAdd); this._cachedNonBuiltInFields = undefined; Element.prototype['setParent'].call(toAdd, this); // Note that we need to wait we have attached the field to set the type. if (type) { toAdd.type = type; } this.onModification(); return toAdd; } *allChildElements(): Generator, void, undefined> { for (const field of this._fields.values()) { yield field; yield* field.arguments(); } } private removeInterfaceImplementation(itf: InterfaceType) { this._interfaceImplementations?.delete(itf.name); removeReferenceToType(this, itf); } protected removeTypeReference(type: NamedType) { this._interfaceImplementations?.delete(type.name); } protected removeInnerElements(): void { for (const interfaceImpl of this.interfaceImplementations()) { interfaceImpl.remove(); } for (const field of this.allFields()) { if (field.isBuiltIn) { // Calling remove on a built-in (think _typename) throws, with reason (we don't want // to allow removing _typename from a type in general). So all we do for built-in is // detach the parent. FieldDefinition.prototype['removeParent'].call(field); } else { field.remove(); } } } protected hasNonExtensionInnerElements(): boolean { return this.interfaceImplementations().some(itf => itf.ofExtension() === undefined) || this.fields().some(f => f.ofExtension() === undefined); } protected removeInnerElementsExtensions(): void { this.interfaceImplementations().forEach(itf => itf.removeOfExtension()); this.fields().forEach(f => f.removeOfExtension()); } } export class ObjectType extends FieldBasedType { readonly kind = 'ObjectType' as const; readonly astDefinitionKind = Kind.OBJECT_TYPE_DEFINITION; /** * Whether this type is one of the schema root type (will return false if the type is detached). */ isRootType(): boolean { const schema = this.schema(); return schema.schemaDefinition.roots().some(rt => rt.type == this); } /** * Whether this type is the "query" root type of the schema (will return false if the type is detached). */ isQueryRootType(): boolean { const schema = this.schema(); return schema.schemaDefinition.root('query')?.type === this; } /** * Whether this type is the "subscription" root type of the schema (will return false if the type is detached). */ isSubscriptionRootType(): boolean { const schema = this.schema(); return schema.schemaDefinition.root('subscription')?.type === this; } protected removeReferenceRecursive(ref: ObjectTypeReferencer): void { // Note that the ref can also be a`SchemaDefinition`, but don't have anything to do then. switch (ref.kind) { case 'FieldDefinition': ref.removeRecursive(); break; case 'UnionType': if (ref.membersCount() === 0) { ref.removeRecursive(); } break; } } unionsWhereMember(): readonly UnionType[] { const unions: UnionType[] = []; this._referencers?.forEach((r) => { if (r instanceof BaseNamedType && isUnionType(r)) { unions.push(r); } }); return unions; } } export class InterfaceType extends FieldBasedType { readonly kind = 'InterfaceType' as const; readonly astDefinitionKind = Kind.INTERFACE_TYPE_DEFINITION; allImplementations(): (ObjectType | InterfaceType)[] { const implementations: (ObjectType | InterfaceType)[] = []; this.referencers().forEach(ref => { if (ref.kind === 'ObjectType' || ref.kind === 'InterfaceType') { implementations.push(ref); } }); return implementations; } possibleRuntimeTypes(): readonly ObjectType[] { // Note that object types in GraphQL needs to reference directly all the interfaces they implement, and cannot rely on transitivity. return this.allImplementations().filter(impl => impl.kind === 'ObjectType') as ObjectType[]; } isPossibleRuntimeType(type: string | NamedType): boolean { const typeName = typeof type === 'string' ? type : type.name; return this.possibleRuntimeTypes().some(t => t.name == typeName); } protected removeReferenceRecursive(ref: InterfaceTypeReferencer): void { // Note that an interface can be referenced by an object/interface that implements it, but after remove(), said object/interface // will simply not implement "this" anymore and we have nothing more to do. if (ref.kind === 'FieldDefinition') { ref.removeRecursive(); } } } export class UnionMember extends BaseExtensionMember { constructor(readonly type: ObjectType) { super(); } protected removeInner() { UnionType.prototype['removeMember'].call(this._parent, this.type); } } export class UnionType extends BaseNamedType { readonly kind = 'UnionType' as const; readonly astDefinitionKind = Kind.UNION_TYPE_DEFINITION; protected readonly _members: MapWithCachedArrays = new MapWithCachedArrays(); private _typenameField?: FieldDefinition; protected onAttached() { // Note that we can only create the __typename built-in field when we're attached, because we need access to the // schema string type. Also, we're effectively modifying a built-in (to add the type), so we // need to let the schema accept it. Schema.prototype['runWithBuiltInModificationAllowed'].call(this.schema(), () => { this._typenameField = new FieldDefinition(typenameFieldName, true); Element.prototype['setParent'].call(this._typenameField, this); this._typenameField.type = new NonNullType(this.schema().stringType()); }); } types(): ObjectType[] { return this.members().map(m => m.type); } members(): readonly UnionMember[] { return this._members.values(); } membersCount(): number { return this._members.size; } hasTypeMember(type: string | ObjectType) { return this._members.has(typeof type === 'string' ? type : type.name); } addType(nameOrTypeOrMember: ObjectType | string | UnionMember): UnionMember { let toAdd: UnionMember; if (nameOrTypeOrMember instanceof UnionMember) { this.checkUpdate(nameOrTypeOrMember); toAdd = nameOrTypeOrMember; } else { let obj: ObjectType; if (typeof nameOrTypeOrMember === 'string') { this.checkUpdate(); const maybeObj = this.schema().type(nameOrTypeOrMember); if (!maybeObj) { throw ERRORS.INVALID_GRAPHQL.err(`Cannot add unknown type ${nameOrTypeOrMember} as member of union type ${this.name}`); } else if (maybeObj.kind != 'ObjectType') { throw ERRORS.INVALID_GRAPHQL.err(`Cannot add non-object type ${nameOrTypeOrMember} (of type ${maybeObj.kind}) as member of union type ${this.name}`); } obj = maybeObj; } else { this.checkUpdate(nameOrTypeOrMember); obj = nameOrTypeOrMember; } toAdd = new UnionMember(obj); } const existing = this._members.get(toAdd.type.name); if (!existing) { this._members.set(toAdd.type.name, toAdd); Element.prototype['setParent'].call(toAdd, this); addReferenceToType(this, toAdd.type); this.onModification(); return toAdd; } else { return existing; } } clearTypes() { for (const type of this.types()) { this.removeMember(type); } this.onModification(); } /** * Access a field of the union by name. * As the only field that can be accessed on an union is the __typename one, this method will always return undefined unless called * on "__typename". However, this exists to allow code working on CompositeType to be more generic. */ field(name: string): FieldDefinition | undefined { if (name === typenameFieldName && this._typenameField) { return this._typenameField; } return undefined; } /** * The __typename field (and only field of a union). * * Note that _attached_ unions always have this field, so this method will only return `undefined` on detached objects. */ typenameField(): FieldDefinition | undefined { return this._typenameField; } private removeMember(type: ObjectType) { this._members.delete(type.name); removeReferenceToType(this, type); } protected removeTypeReference(type: NamedType) { this._members.delete(type.name); } protected removeInnerElements(): void { for (const member of this.members()) { member.remove(); } } protected hasNonExtensionInnerElements(): boolean { return this.members().some(m => m.ofExtension() === undefined); } protected removeReferenceRecursive(ref: OutputTypeReferencer): void { ref.removeRecursive(); } protected removeInnerElementsExtensions(): void { this.members().forEach(m => m.removeOfExtension()); } } export class EnumType extends BaseNamedType { readonly kind = 'EnumType' as const; readonly astDefinitionKind = Kind.ENUM_TYPE_DEFINITION; private _values = new Map(); get values(): readonly EnumValue[] { // Because our abstractions are mutable, and removal is done by calling // `remove()` on the element to remove, it's not unlikely someone mauy // try to iterate on the result of this method and call `remove()` on // some of the return value based on some condition. But this will break // in an error-prone way if we don't copy, so we do. return Array.from(this._values.values()); } value(name: string): EnumValue | undefined { return this._values.get(name); } addValue(value: EnumValue): EnumValue; addValue(name: string): EnumValue; addValue(nameOrValue: EnumValue | string): EnumValue { let toAdd: EnumValue; if (typeof nameOrValue === 'string') { this.checkUpdate(); toAdd = new EnumValue(nameOrValue); } else { this.checkUpdate(nameOrValue); toAdd = nameOrValue; } const existing = this.value(toAdd.name); if (!existing) { this._values.set(toAdd.name, toAdd); Element.prototype['setParent'].call(toAdd, this); this.onModification(); return toAdd; } else { return existing; } } protected removeTypeReference(type: NamedType) { assert(false, `Eum type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } private removeValueInternal(value: EnumValue) { this._values.delete(value.name); } protected removeInnerElements(): void { // Make a copy (indirectly), since EnumValue.remove() will modify this._values. const values = this.values; for (const value of values) { value.remove(); } } protected hasNonExtensionInnerElements(): boolean { return Array.from(this._values.values()).some(v => v.ofExtension() === undefined); } protected removeReferenceRecursive(ref: OutputTypeReferencer): void { ref.removeRecursive(); } protected removeInnerElementsExtensions(): void { for (const v of this._values.values()) { v.removeOfExtension(); } } } export class InputObjectType extends BaseNamedType { readonly kind = 'InputObjectType' as const; readonly astDefinitionKind = Kind.INPUT_OBJECT_TYPE_DEFINITION; private readonly _fields: Map = new Map(); private _cachedFieldsArray?: InputFieldDefinition[]; /** * All the fields of this input type. */ fields(): InputFieldDefinition[] { if (!this._cachedFieldsArray) { this._cachedFieldsArray = mapValues(this._fields); } return this._cachedFieldsArray; } field(name: string): InputFieldDefinition | undefined { return this._fields.get(name); } addField(field: InputFieldDefinition): InputFieldDefinition; addField(name: string, type?: Type): InputFieldDefinition; addField(nameOrField: string | InputFieldDefinition, type?: Type): InputFieldDefinition { const toAdd = typeof nameOrField === 'string' ? new InputFieldDefinition(nameOrField) : nameOrField; this.checkUpdate(toAdd); if (this.field(toAdd.name)) { throw ERRORS.INVALID_GRAPHQL.err(`Field ${toAdd.name} already exists on ${this}`); } if (type && !isInputType(type)) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid output type ${type} for field ${toAdd.name}: input field types should be input types.`); } this._fields.set(toAdd.name, toAdd); this._cachedFieldsArray = undefined; Element.prototype['setParent'].call(toAdd, this); // Note that we need to wait we have attached the field to set the type. if (typeof nameOrField === 'string' && type) { toAdd.type = type; } this.onModification(); return toAdd; } hasFields(): boolean { return this._fields.size > 0; } *allChildElements(): Generator, void, undefined> { yield* this._fields.values(); } protected removeTypeReference(type: NamedType) { assert(false, `Input Object type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } protected removeInnerElements(): void { // Not that we modify the type during iteration, but the reference we get from `this.fields()` will not change for (const field of this.fields()) { field.remove(); } } private removeFieldInternal(field: InputFieldDefinition) { this._fields.delete(field.name); this._cachedFieldsArray = undefined; } protected hasNonExtensionInnerElements(): boolean { return this.fields().some(f => f.ofExtension() === undefined); } protected removeReferenceRecursive(ref: InputTypeReferencer): void { if (ref.kind === 'ArgumentDefinition') { // Not only do we want to remove the argument, but we want to remove its parent. Technically, only removing the argument would // leave the schema in a valid state so it would be an option, but this feel a bit too weird of a behaviour in practice for a // method calling `removeRecursive`. And in particular, it would mean that if the argument is a directive definition one, // we'd also have to update each of the directive application to remove the correspond argument. Removing the full directive // definition (and all its applications) feels a bit more predictable. ref.parent().removeRecursive(); } else { ref.removeRecursive(); } } protected removeInnerElementsExtensions(): void { this.fields().forEach(f => f.removeOfExtension()); } } class BaseWrapperType { protected constructor(protected _type: T) { assert(this._type, 'Cannot wrap an undefined/null type'); } schema(): Schema { return this.baseType().schema(); } isAttached(): boolean { return this.baseType().isAttached(); } get ofType(): T { return this._type; } baseType(): NamedType { return baseType(this._type); } } export class ListType extends BaseWrapperType { readonly kind = 'ListType' as const; constructor(type: T) { super(type); } toString(): string { return `[${this.ofType}]`; } } export class NonNullType extends BaseWrapperType { readonly kind = 'NonNullType' as const; constructor(type: T) { super(type); } toString(): string { return `${this.ofType}!`; } } export class FieldDefinition extends NamedSchemaElementWithType, TParent, never> { readonly kind = 'FieldDefinition' as const; private _args: MapWithCachedArrays>> | undefined; private _extension?: Extension; constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); } protected isElementBuiltIn(): boolean { return this.isBuiltIn; } get coordinate(): string { const parent = this._parent; return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } hasArguments(): boolean { return !!this._args && this._args.size > 0; } arguments(): readonly ArgumentDefinition>[] { return this._args?.values() ?? []; } argument(name: string): ArgumentDefinition> | undefined { return this._args?.get(name); } addArgument(arg: ArgumentDefinition>): ArgumentDefinition>; addArgument(name: string, type?: Type, defaultValue?: any): ArgumentDefinition>; addArgument(nameOrArg: string | ArgumentDefinition>, type?: Type, defaultValue?: any): ArgumentDefinition> { let toAdd: ArgumentDefinition>; if (typeof nameOrArg === 'string') { this.checkUpdate(); toAdd = new ArgumentDefinition>(nameOrArg); toAdd.defaultValue = defaultValue; } else { this.checkUpdate(nameOrArg); toAdd = nameOrArg; } const existing = this.argument(toAdd.name); if (existing) { // For some reason (bad codegen, maybe?), some users have field where a arg is defined more than one. And this doesn't seem rejected by // graphQL (?). So we accept it, but ensure the types/default values are the same. if (type && existing.type && !sameType(type, existing.type)) { throw ERRORS.INVALID_GRAPHQL.err(`Argument ${toAdd.name} already exists on field ${this.name} with a different type (${existing.type})`); } if (defaultValue && (!existing.defaultValue || !valueEquals(defaultValue, existing.defaultValue))) { throw ERRORS.INVALID_GRAPHQL.err(`Argument ${toAdd.name} already exists on field ${this.name} with a different default value (${valueToString(existing.defaultValue)})`); } return existing; } if (type && !isInputType(type)) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid output type ${type} for argument ${toAdd.name} of ${this}: arguments should be input types.`); } if (!this._args) { this._args = new MapWithCachedArrays(); } this._args.set(toAdd.name, toAdd); Element.prototype['setParent'].call(toAdd, this); if (typeof nameOrArg === 'string') { toAdd.type = type; } this.onModification(); return toAdd; } ofExtension(): Extension | undefined { return this._extension; } removeOfExtension() { this._extension = undefined; } setOfExtension(extension: Extension | undefined) { this.checkUpdate(); assert( !extension || this._parent?.hasExtension(extension), () => `Cannot mark field ${this.name} as part of the provided extension: it is not an extension of field parent type ${this.parent}` ); this._extension = extension; this.onModification(); } isIntrospectionField(): boolean { return isIntrospectionName(this.name); } isSchemaIntrospectionField(): boolean { return introspectionFieldNames.includes(this.name); } private removeArgumentInternal(name: string) { if (this._args) { this._args.delete(name); } } // Only called through the prototype from FieldBasedType.removeInnerElements because we don't want to expose it. private removeParent() { this._parent = undefined; } isDeprecated(): boolean { return this.hasAppliedDirective('deprecated'); } /** * Removes this field definition from its parent type. * * After calling this method, this field definition will be "detached": it will have no parent, schema, type, * arguments, or directives. */ remove(): never[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this field's children. this.sourceAST = undefined; this.type = undefined; this.removeAppliedDirectives(); for (const arg of this.arguments()) { arg.remove(); } // Note that we don't track field references outside of parents, so no // removal needed there. // // TODO: One could consider interface fields as references to implementing // object/interface fields, in the sense that removing an implementing // object/interface field breaks the validity of the implementing // interface field. Being aware that an object/interface field is being // referenced in such a way would be useful for understanding breakages // that need to be resolved as a consequence of removal. // // Remove this field from its parent object/interface type. FieldBasedType.prototype['removeFieldInternal'].call(this._parent, this); this._parent = undefined; this._extension = undefined; return []; } /** * Like `remove()`, but if this field was the last field of its parent type, the parent type is removed through its `removeRecursive` method. */ removeRecursive(): void { const parent = this._parent; this.remove(); // Note that we exclude the union type here because it doesn't have the `fields()` method, but the only field unions can have is the __typename // one and it cannot be removed, so remove() above will actually throw in practice before reaching this. if (parent && !isUnionType(parent) && parent.fields().length === 0) { parent.removeRecursive(); } } toString(): string { const args = this.hasArguments() ? '(' + this.arguments().map(arg => arg.toString()).join(', ') + ')' : ""; return `${this.name}${args}: ${this.type}`; } } export class InputFieldDefinition extends NamedSchemaElementWithType { readonly kind = 'InputFieldDefinition' as const; private _extension?: Extension; defaultValue?: any get coordinate(): string { const parent = this._parent; return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } isRequired(): boolean { return isNonNullType(this.type!) && this.defaultValue === undefined; } ofExtension(): Extension | undefined { return this._extension; } removeOfExtension() { this._extension = undefined; } setOfExtension(extension: Extension | undefined) { this.checkUpdate(); assert( !extension || this._parent?.hasExtension(extension), () => `Cannot mark field ${this.name} as part of the provided extension: it is not an extension of field parent type ${this.parent}`, ); this._extension = extension; this.onModification(); } isDeprecated(): boolean { return this.hasAppliedDirective('deprecated'); } /** * Removes this input field definition from its parent type. * * After calling this method, this input field definition will be "detached": it will have no parent, schema, * type, default value, or directives. */ remove(): never[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this input field's children. this.sourceAST = undefined; this.type = undefined; this.defaultValue = undefined; this.removeAppliedDirectives(); // Note that we don't track input field references outside of parents, so no // removal needed there. // // TODO: One could consider default values (in field arguments, input // fields, or directive definitions) as references to input fields they // use, in the sense that removing the input field breaks the validity of // the default value. Being aware that an input field is being referenced // in such a way would be useful for understanding breakages that need to // be resolved as a consequence of removal. (The reference is indirect // though, as input field usages are currently represented as strings // within GraphQL values). // // Remove this input field from its parent input object type. InputObjectType.prototype['removeFieldInternal'].call(this._parent, this); this._parent = undefined; this._extension = undefined; return []; } /** * Like `remove()`, but if this field was the last field of its parent type, the parent type is removed through its `removeRecursive` method. */ removeRecursive(): void { const parent = this._parent; this.remove(); if (parent && parent.fields().length === 0) { parent.removeRecursive(); } } toString(): string { const defaultStr = this.defaultValue === undefined ? "" : ` = ${valueToString(this.defaultValue, this.type)}`; return `${this.name}: ${this.type}${defaultStr}`; } } export class ArgumentDefinition | DirectiveDefinition> extends NamedSchemaElementWithType, TParent, never> { readonly kind = 'ArgumentDefinition' as const; defaultValue?: any constructor(name: string) { super(name); } get coordinate(): string { const parent = this._parent; return `${parent == undefined ? '' : parent.coordinate}(${this.name}:)`; } isRequired(): boolean { return isNonNullType(this.type!) && this.defaultValue === undefined; } isDeprecated(): boolean { return this.hasAppliedDirective('deprecated'); } /** * Removes this argument definition from its parent element (field or directive). * * After calling this method, this argument definition will be "detached": it will have no parent, schema, type, * default value, or directives. */ remove(): never[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this argument's children. this.sourceAST = undefined; this.type = undefined; this.defaultValue = undefined; this.removeAppliedDirectives(); // Note that we don't track argument references outside of parents, so no // removal needed there. // // TODO: One could consider the arguments of directive applications as // references to the arguments of directive definitions, in the sense that // removing a directive definition argument can break the validity of the // directive application. Being aware that a directive definition argument // is being referenced in such a way would be useful for understanding // breakages that need to be resolved as a consequence of removal. (You // could make a similar claim about interface field arguments being // references to object field arguments.) // // Remove this argument from its parent field or directive definition. if (this._parent instanceof FieldDefinition) { FieldDefinition.prototype['removeArgumentInternal'].call(this._parent, this.name); } else { DirectiveDefinition.prototype['removeArgumentInternal'].call(this._parent, this.name); } this._parent = undefined; return []; } toString() { const defaultStr = this.defaultValue === undefined ? "" : ` = ${valueToString(this.defaultValue, this.type)}`; return `${this.name}: ${this.type}${defaultStr}`; } } export class EnumValue extends NamedSchemaElement { readonly kind = 'EnumValue' as const; private _extension?: Extension; get coordinate(): string { const parent = this._parent; return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } ofExtension(): Extension | undefined { return this._extension; } removeOfExtension() { this._extension = undefined; } setOfExtension(extension: Extension | undefined) { this.checkUpdate(); assert( !extension || this._parent?.hasExtension(extension), () => `Cannot mark field ${this.name} as part of the provided extension: it is not an extension of enum value parent type ${this.parent}`, ); this._extension = extension; this.onModification(); } isDeprecated(): boolean { return this.hasAppliedDirective('deprecated'); } /** * Removes this enum value definition from its parent type. * * After calling this method, this enum value definition will be "detached": it will have no parent, schema, type, * arguments, or directives. */ remove(): never[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this enum value's children. this.sourceAST = undefined; this.removeAppliedDirectives(); // Note that we don't track enum value references outside of parents, so no // removal needed there. // // TODO: One could consider default values (in field arguments, input // fields, or directive definitions) as references to enum values they // use, in the sense that removing the enum value breaks the validity of // the default value. Being aware that an enum value is being referenced // in such a way would be useful for understanding breakages that need to // be resolved as a consequence of removal. (The reference is indirect // though, as enum value usages are currently represented as strings // within GraphQL values). // // Remove this enum value from its parent enum type. EnumType.prototype['removeValueInternal'].call(this._parent, this); this._parent = undefined; this._extension = undefined; return []; } protected removeTypeReference(type: NamedType) { assert(false, `Enum value ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } toString(): string { return `${this.name}`; } } export class DirectiveDefinition extends NamedSchemaElement, Schema, Directive> { readonly kind = 'DirectiveDefinition' as const; private _args?: MapWithCachedArrays>; repeatable: boolean = false; private readonly _locations: DirectiveLocation[] = []; private _referencers?: Set, TApplicationArgs>>; constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); } get coordinate(): string { return `@${this.name}`; } arguments(): readonly ArgumentDefinition[] { return this._args?.values() ?? []; } argument(name: string): ArgumentDefinition | undefined { return this._args?.get(name); } addArgument(arg: ArgumentDefinition): ArgumentDefinition; addArgument(name: string, type?: InputType, defaultValue?: any): ArgumentDefinition; addArgument(nameOrArg: string | ArgumentDefinition, type?: InputType, defaultValue?: any): ArgumentDefinition { let toAdd: ArgumentDefinition; if (typeof nameOrArg === 'string') { this.checkUpdate(); toAdd = new ArgumentDefinition(nameOrArg); toAdd.defaultValue = defaultValue; } else { this.checkUpdate(nameOrArg); toAdd = nameOrArg; } if (this.argument(toAdd.name)) { throw ERRORS.INVALID_GRAPHQL.err(`Argument ${toAdd.name} already exists on field ${this.name}`); } if (!this._args) { this._args = new MapWithCachedArrays(); } this._args.set(toAdd.name, toAdd); Element.prototype['setParent'].call(toAdd, this); if (typeof nameOrArg === 'string') { toAdd.type = type; } this.onModification(); return toAdd; } private removeArgumentInternal(name: string) { this._args?.delete(name); } get locations(): readonly DirectiveLocation[] { return this._locations; } addLocations(...locations: DirectiveLocation[]): DirectiveDefinition { let modified = false; for (const location of locations) { if (!this._locations.includes(location)) { this._locations.push(location); modified = true; } } if (modified) { this.onModification(); } return this; } addAllLocations(): DirectiveDefinition { return this.addLocations(...Object.values(DirectiveLocation)); } /** * Adds the subset of type system locations that correspond to type definitions. */ addAllTypeLocations(): DirectiveDefinition { return this.addLocations( DirectiveLocation.SCALAR, DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE, DirectiveLocation.UNION, DirectiveLocation.ENUM, DirectiveLocation.INPUT_OBJECT, ); } removeLocations(...locations: DirectiveLocation[]): DirectiveDefinition { let modified = false; for (const location of locations) { modified ||= removeArrayElement(location, this._locations); } if (modified) { this.onModification(); } return this; } hasExecutableLocations(): boolean { return this.locations.some((loc) => isExecutableDirectiveLocation(loc)); } hasTypeSystemLocations(): boolean { return this.locations.some((loc) => isTypeSystemDirectiveLocation(loc)); } applications(): ReadonlySet, TApplicationArgs>> { this._referencers ??= new Set(); return this._referencers; } private addReferencer(referencer: Directive, TApplicationArgs>) { assert(referencer, 'Referencer should exists'); this._referencers ??= new Set(); this._referencers.add(referencer); } private removeReferencer(referencer: Directive, TApplicationArgs>) { this._referencers?.delete(referencer); } protected removeTypeReference(type: NamedType) { assert(false, `Directive definition ${this} can't reference other types (it's arguments can); shouldn't be asked to remove reference to ${type}`); } /** * Removes this directive definition from its parent schema. * * After calling this method, this directive definition will be "detached": it will have no parent, schema, or * arguments. */ remove(): Directive[] { if (!this._parent) { return []; } this.checkRemoval(); this.onModification(); // Remove this directive definition's children. this.sourceAST = undefined; assert(!this._appliedDirectives || this._appliedDirectives.length === 0, "Directive definition should not have directive applied to it"); for (const arg of this.arguments()) { arg.remove(); } // Remove this directive definition's references. // // Note that while a directive application references its definition, it // doesn't store a link to that definition. Instead, we fetch the definition // from the schema when requested. So we don't have to do anything on the // referencers other than clear them (and return the pre-cleared set). const toReturn = Array.from(this._referencers ?? []); this._referencers = undefined; // Remove this directive definition from its parent schema. Schema.prototype['removeDirectiveInternal'].call(this._parent, this); this._parent = undefined; return toReturn; } /** * Removes this this directive definition _and_ all its applications. */ removeRecursive(): void { this.remove().forEach(ref => ref.remove()); } toAST(): DirectiveDefinitionNode { const doc = parse(printDirectiveDefinition(this)); return doc.definitions[0] as DirectiveDefinitionNode; } toString(): string { return `@${this.name}`; } } export class Directive< TParent extends SchemaElement | DirectiveTargetElement = SchemaElement, TArgs extends {[key: string]: any} = {[key: string]: any} > extends Element implements Named { // Note that _extension will only be set for directive directly applied to an extendable element. Meaning that if a directive is // applied to a field that is part of an extension, the field will have its extension set, but not the underlying directive. private _extension?: Extension; constructor(readonly name: string, private _args: TArgs = Object.create(null)) { super(); } schema(): Schema { return this.parent.schema(); } get definition(): DirectiveDefinition | undefined { if (!this.isAttached()) { return undefined; } const doc = this.schema(); return doc.directive(this.name); } arguments(includeDefaultValues: boolean = false) : Readonly { if (!includeDefaultValues) { return this._args; } const definition = this.definition; assert(definition, () => `Cannot include default values for arguments: cannot find directive definition for ${this.name}`); const updated = Object.create(null); for (const argDef of definition.arguments()) { const argValue = withDefaultValues(this._args[argDef.name], argDef); // Note that argValue could be '0' or something falsy here, so we must explicitly check === undefined if (argValue !== undefined) { updated[argDef.name] = argValue; } } return updated; } private onModification() { if (this.isAttachedToSchemaElement()) { Schema.prototype['onModification'].call(this.schema()); } } private isAttachedToSchemaElement(): boolean { return this.isAttached(); } setArguments(args: TArgs) { this._args = args; this.onModification(); } argumentType(name: string): InputType | undefined { return this.definition?.argument(name)?.type; } matchArguments(expectedArgs: Record): boolean { const entries = Object.entries(this._args); if (entries.length !== Object.keys(expectedArgs).length) { return false; } for (const [key, val] of entries) { if (!(key in expectedArgs)) { return false; } const expectedVal = expectedArgs[key]; if (!valueEquals(expectedVal, val)) { return false; } } return true; } ofExtension(): Extension | undefined { return this._extension; } removeOfExtension() { this._extension = undefined; } setOfExtension(extension: Extension | undefined) { this.checkUpdate(); if (extension) { const parent = this.parent; assert( parent instanceof SchemaDefinition || parent instanceof BaseNamedType, 'Can only mark directive parts of extensions when directly apply to type or schema definition.' ); assert(parent.hasExtension(extension), () => `Cannot mark directive ${this.name} as part of the provided extension: it is not an extension of parent ${parent}`); } this._extension = extension; this.onModification(); } argumentsToAST(): ConstArgumentNode[] | undefined { const entries = Object.entries(this._args); if (entries.length === 0) { return undefined; } const definition = this.definition; assert(definition, () => `Cannot convert arguments of detached directive ${this}`); return entries.map(([n, v]) => { return { kind: Kind.ARGUMENT, name: { kind: Kind.NAME, value: n }, value: valueToAST(v, definition.argument(n)!.type!)! as ConstValueNode, }; }); } /** * Removes this directive application from its parent type. * * @returns whether the directive was actually removed, that is whether it had a parent. */ remove(): boolean { if (!this._parent) { return false; } this.onModification(); const coreFeatures = this.schema().coreFeatures; if (coreFeatures && this.name === coreFeatures.coreItself.nameInSchema) { // We're removing a @core/@link directive application, so we remove it from the list of core features. And // if it is @core itself, we clean all features (to avoid having things too inconsistent). const url = FeatureUrl.parse(this._args[coreFeatures.coreDefinition.urlArgName()]!); if (url.identity === coreFeatures.coreItself.url.identity) { // Note that we unmark first because the loop after that will nuke our parent. Schema.prototype['unmarkAsCoreSchema'].call(this.schema()); for (const d of this.schema().schemaDefinition.appliedDirectivesOf(coreFeatures.coreItself.nameInSchema)) { d.removeInternal(); } // The loop above will already have call removeInternal on this instance, so we can return return true; } else { CoreFeatures.prototype['removeFeature'].call(coreFeatures, url.identity); } } return this.removeInternal(); } private removeInternal(): boolean { if (!this._parent) { return false; } // Remove this directive application's reference to its definition. const definition = this.definition; if (definition && this.isAttachedToSchemaElement()) { DirectiveDefinition.prototype['removeReferencer'].call(definition, this as Directive>); } // Remove this directive application from its parent schema element. const parentDirectives = this._parent.appliedDirectives as Directive[]; const removed = removeArrayElement(this, parentDirectives); assert(removed, () => `Directive ${this} lists ${this._parent} as parent, but that parent doesn't list it as applied directive`); this._parent = undefined; this._extension = undefined; return true; } toString(): string { const entries = Object.entries(this._args).filter(([_, v]) => v !== undefined); const args = entries.length == 0 ? '' : '(' + entries.map(([n, v]) => `${n}: ${valueToString(v, this.argumentType(n))}`).join(', ') + ')'; return `@${this.name}${args}`; } } /** * Formats a Directive array as a string (with a leading space, if present). */ export function directivesToString(directives?: readonly Directive[]) : string { return (!directives || directives.length == 0) ? '' : ' ' + directives.join(' '); } /** * Converts a Directive array into DirectiveNode array. */ export function directivesToDirectiveNodes(directives?: readonly Directive[]) : ConstDirectiveNode[] | undefined { return (!directives || directives.length === 0) ? undefined : directives.map(directive => { return { kind: Kind.DIRECTIVE, name: { kind: Kind.NAME, value: directive.name, }, arguments: directive.argumentsToAST() }; }); } /** * Checks if 2 directive applications should be considered equal. * * By default, 2 directive applications are considered equal if they are for the same directive and are passed the same values to * the same arguments. However, some special directive can be excluded so that no 2 applications are ever consider equal. By default, * this is the case of @defer, as never want to merge @defer applications so that each create its own "deferred block". */ export function sameDirectiveApplication( application1: Directive, application2: Directive, directivesNeverEqualToThemselves: string[] = [ 'defer' ], ): boolean { // Note: we check name equality first because this method is most often called with directive that are simply not the same // name and this ensure we exit cheaply more often than not. return application1.name === application2.name && !directivesNeverEqualToThemselves.includes(application1.name) && !directivesNeverEqualToThemselves.includes(application2.name) && argumentsEquals(application1.arguments(), application2.arguments()); } /** * Checks whether the 2 provided "set" of directive applications are the same (same applications, regardless or order). */ export function sameDirectiveApplications( applications1: readonly Directive[], applications2: readonly Directive[], directivesNeverEqualToThemselves: string[] = [ 'defer' ], ): boolean { if (applications1.length !== applications2.length) { return false; } for (const directive1 of applications1) { if (!applications2.some(directive2 => sameDirectiveApplication(directive1, directive2, directivesNeverEqualToThemselves))) { return false; } } return true; } /** * Checks whether a given array of directive applications (`maybeSubset`) is a sub-set of another array of directive applications (`applications`). * * Sub-set here means that all of the applications in `maybeSubset` appears in `applications`. */ export function isDirectiveApplicationsSubset(applications: readonly Directive[], maybeSubset: readonly Directive[]): boolean { if (maybeSubset.length > applications.length) { return false; } for (const directive1 of maybeSubset) { if (!applications.some(directive2 => sameDirectiveApplication(directive1, directive2))) { return false; } } return true; } /** * Computes the difference between the set of directives applications `baseApplications` and the `toRemove` one. */ export function directiveApplicationsSubstraction(baseApplications: readonly Directive[], toRemove: readonly Directive[]): Directive[] { return baseApplications.filter((application) => !toRemove.some((other) => sameDirectiveApplication(application, other))); } export class Variable { constructor(readonly name: string) {} toVariableNode(): VariableNode { return { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: this.name }, } } toString(): string { return '$' + this.name; } } export type Variables = readonly Variable[]; export class VariableCollector { private readonly _variables = new Map(); add(variable: Variable) { this._variables.set(variable.name, variable); } addAll(variables: Variables) { for (const variable of variables) { this.add(variable); } } collectInArguments(args: {[key: string]: any}) { for (const value of Object.values(args)) { collectVariablesInValue(value, this); } } variables() { return mapValues(this._variables); } toString(): string { return this.variables().toString(); } } export function isVariable(v: any): v is Variable { return v instanceof Variable; } export class VariableDefinition extends DirectiveTargetElement { constructor( schema: Schema, readonly variable: Variable, readonly type: InputType, readonly defaultValue?: any, ) { super(schema); } toVariableDefinitionNode(): VariableDefinitionNode { const ast = valueToAST(this.defaultValue, this.type); return { kind: Kind.VARIABLE_DEFINITION, variable: this.variable.toVariableNode(), type: typeToAST(this.type), defaultValue: (ast !== undefined) ? valueNodeToConstValueNode(ast) : undefined, directives: this.appliedDirectivesToDirectiveNodes(), } } toString() { let base = this.variable + ': ' + this.type; if (this.defaultValue !== undefined) { base = base + ' = ' + valueToString(this.defaultValue, this.type); } return base + this.appliedDirectivesToString(); } } export class VariableDefinitions { private readonly _definitions: MapWithCachedArrays = new MapWithCachedArrays(); add(definition: VariableDefinition): boolean { if (this._definitions.has(definition.variable.name)) { return false; } this._definitions.set(definition.variable.name, definition); return true; } addAll(definitions: VariableDefinitions) { for (const definition of definitions._definitions.values()) { this.add(definition); } } definition(variable: Variable | string): VariableDefinition | undefined { const varName = typeof variable === 'string' ? variable : variable.name; return this._definitions.get(varName); } isEmpty(): boolean { return this._definitions.size === 0; } definitions(): readonly VariableDefinition[] { return this._definitions.values(); } filter(variables: Variables): VariableDefinitions { if (variables.length === 0) { return new VariableDefinitions(); } const newDefs = new VariableDefinitions(); for (const variable of variables) { const def = this.definition(variable); if (!def) { throw new Error(`Cannot find variable ${variable} in definitions ${this}`); } newDefs.add(def); } return newDefs; } toVariableDefinitionNodes(): readonly VariableDefinitionNode[] | undefined { if (this._definitions.size === 0) { return undefined; } return this.definitions().map(def => def.toVariableDefinitionNode()); } toString() { return '(' + this.definitions().join(', ') + ')'; } } export function variableDefinitionsFromAST(schema: Schema, definitionNodes: readonly VariableDefinitionNode[]): VariableDefinitions { const definitions = new VariableDefinitions(); for (const definitionNode of definitionNodes) { if (!definitions.add(variableDefinitionFromAST(schema, definitionNode))) { const name = definitionNode.variable.name.value; throw ERRORS.INVALID_GRAPHQL.err(`Duplicate definition for variable ${name}`, { nodes: definitionNodes.filter(n => n.variable.name.value === name) }); } } return definitions; } export function variableDefinitionFromAST(schema: Schema, definitionNode: VariableDefinitionNode): VariableDefinition { const variable = new Variable(definitionNode.variable.name.value); const type = typeFromAST(schema, definitionNode.type); if (!isInputType(type)) { throw ERRORS.INVALID_GRAPHQL.err(`Invalid type "${type}" for variable $${variable}: not an input type`, { nodes: definitionNode.type }); } const def = new VariableDefinition( schema, variable, type, definitionNode.defaultValue ? valueFromAST(definitionNode.defaultValue, type) : undefined ); return def; } function addReferenceToType(referencer: SchemaElement, type: Type) { switch (type.kind) { case 'ListType': addReferenceToType(referencer, type.baseType()); break; case 'NonNullType': addReferenceToType(referencer, type.baseType()); break; default: BaseNamedType.prototype['addReferencer'].call(type, referencer); break; } } function removeReferenceToType(referencer: SchemaElement, type: Type) { switch (type.kind) { case 'ListType': removeReferenceToType(referencer, type.baseType()); break; case 'NonNullType': removeReferenceToType(referencer, type.baseType()); break; default: BaseNamedType.prototype['removeReferencer'].call(type, referencer); break; } } export function newNamedType(kind: NamedTypeKind, name: string): NamedType { switch (kind) { case 'ScalarType': return new ScalarType(name); case 'ObjectType': return new ObjectType(name); case 'InterfaceType': return new InterfaceType(name); case 'UnionType': return new UnionType(name); case 'EnumType': return new EnumType(name); case 'InputObjectType': return new InputObjectType(name); default: assert(false, `Unhandled kind ${kind} for type ${name}`); } } function *typesToCopy(source: Schema, dest: Schema): Generator { for (const type of source.builtInTypes()) { if (!type.isIntrospectionType() && !dest.type(type.name)?.isBuiltIn) { yield type; } } yield* source.types(); } function *directivesToCopy(source: Schema, dest: Schema): Generator { for (const directive of source.builtInDirectives()) { if (!dest.directive(directive.name)?.isBuiltIn) { yield directive; } } yield* source.directives(); } /** * Creates, in the provided schema, a directive definition equivalent to the provided one. * * Note that this method assumes that: * - the provided schema does not already have a directive with the name of the definition to copy. * - if the copied definition has arguments, then the provided schema has existing types with * names matching any type used in copied definition. */ export function copyDirectiveDefinitionToSchema({ definition, schema, copyDirectiveApplicationsInArguments = true, locationFilter, }: { definition: DirectiveDefinition, schema: Schema, copyDirectiveApplicationsInArguments: boolean, locationFilter?: (loc: DirectiveLocation) => boolean, } ) { copyDirectiveDefinitionInner( definition, schema.addDirectiveDefinition(definition.name), copyDirectiveApplicationsInArguments, locationFilter, ); } function copy(source: Schema, dest: Schema, cloneJoinDirectives: boolean) { // We shallow copy types first so any future reference to any of them can be dereferenced. for (const type of typesToCopy(source, dest)) { dest.addType(newNamedType(type.kind, type.name)); } // Directives can use other directives in their arguments. So, like types, we first shallow copy // directives so future references to any of them can be dereferenced. We'll copy the actual // definition later after all directives are defined. for (const directive of directivesToCopy(source, dest)) { dest.addDirectiveDefinition(directive.name); } for (const directive of directivesToCopy(source, dest)) { copyDirectiveDefinitionInner(directive, dest.directive(directive.name)!); } copySchemaDefinitionInner(source.schemaDefinition, dest.schemaDefinition); for (const type of typesToCopy(source, dest)) { copyNamedTypeInner(type, dest.type(type.name)!, cloneJoinDirectives); } } function copyExtensions(source: T, dest: T): Map, Extension> { const extensionMap = new Map, Extension>(); for (const sourceExtension of source.extensions()) { const destExtension = new Extension(); dest.addExtension(destExtension as any); extensionMap.set(sourceExtension as any, destExtension); } return extensionMap; } function copyOfExtension( extensionsMap: Map, Extension>, source: { ofExtension(): Extension | undefined }, dest: { setOfExtension(ext: Extension | undefined):any } ) { const toCopy = source.ofExtension(); if (toCopy) { dest.setOfExtension(extensionsMap.get(toCopy)); } } function copySchemaDefinitionInner(source: SchemaDefinition, dest: SchemaDefinition) { dest.preserveEmptyDefinition = source.preserveEmptyDefinition; const extensionsMap = copyExtensions(source, dest); for (const rootType of source.roots()) { copyOfExtension(extensionsMap, rootType, dest.setRoot(rootType.rootKind, rootType.type.name)); } // Same as copyAppliedDirectives, but as the directive applies to the schema definition, we need to remember if the application // is for the extension or not. for (const directive of source.appliedDirectives) { copyOfExtension(extensionsMap, directive, copyAppliedDirective(directive, dest)); } dest.description = source.description; dest.sourceAST = source.sourceAST; } function copyNamedTypeInner(source: NamedType, dest: NamedType, cloneJoinDirectives: boolean) { dest.preserveEmptyDefinition = source.preserveEmptyDefinition; const extensionsMap = copyExtensions(source, dest); // Same as copyAppliedDirectives, but as the directive applies to the type, we need to remember if the application // is for the extension or not. for (const directive of source.appliedDirectives) { copyOfExtension(extensionsMap, directive, copyAppliedDirective(directive, dest)); } dest.description = source.description; dest.sourceAST = source.sourceAST; switch (source.kind) { case 'ObjectType': case 'InterfaceType': const destFieldBasedType = dest as FieldBasedType; for (const sourceField of source.fields()) { const destField = destFieldBasedType.addField(new FieldDefinition(sourceField.name)); copyOfExtension(extensionsMap, sourceField, destField); copyFieldDefinitionInner(sourceField, destField, cloneJoinDirectives); } for (const sourceImpl of source.interfaceImplementations()) { const destImpl = destFieldBasedType.addImplementedInterface(sourceImpl.interface.name); copyOfExtension(extensionsMap, sourceImpl, destImpl); } break; case 'UnionType': const destUnionType = dest as UnionType; for (const sourceType of source.members()) { const destType = destUnionType.addType(sourceType.type.name); copyOfExtension(extensionsMap, sourceType, destType); } break; case 'EnumType': const destEnumType = dest as EnumType; for (const sourceValue of source.values) { const destValue = destEnumType.addValue(sourceValue.name); destValue.description = sourceValue.description; copyOfExtension(extensionsMap, sourceValue, destValue); copyAppliedDirectives(sourceValue, destValue, cloneJoinDirectives); } break case 'InputObjectType': const destInputType = dest as InputObjectType; for (const sourceField of source.fields()) { const destField = destInputType.addField(new InputFieldDefinition(sourceField.name)); copyOfExtension(extensionsMap, sourceField, destField); copyInputFieldDefinitionInner(sourceField, destField, cloneJoinDirectives); } } } function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement, cloneJoinDirectives: boolean) { source.appliedDirectives.filter(d => cloneJoinDirectives || !d.name.startsWith('join__')).forEach((d) => copyAppliedDirective(d, dest)); } function copyAppliedDirective(source: Directive, dest: SchemaElement): Directive { const res = dest.applyDirective(source.name, { ...source.arguments() }); res.sourceAST = source.sourceAST return res; } function copyFieldDefinitionInner

(source: FieldDefinition

, dest: FieldDefinition

, cloneJoinDirectives: boolean) { const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()) as OutputType; dest.type = type; for (const arg of source.arguments()) { const argType = copyWrapperTypeOrTypeRef(arg.type, dest.schema()); copyArgumentDefinitionInner({ source: arg, dest: dest.addArgument(arg.name, argType as InputType), cloneJoinDirectives, }); } copyAppliedDirectives(source, dest, cloneJoinDirectives); dest.description = source.description; dest.sourceAST = source.sourceAST; } function copyInputFieldDefinitionInner(source: InputFieldDefinition, dest: InputFieldDefinition, cloneJoinDirectives: boolean) { const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()) as InputType; dest.type = type; dest.defaultValue = source.defaultValue; copyAppliedDirectives(source, dest, cloneJoinDirectives); dest.description = source.description; dest.sourceAST = source.sourceAST; } function copyWrapperTypeOrTypeRef(source: Type | undefined, destParent: Schema): Type | undefined { if (!source) { return undefined; } switch (source.kind) { case 'ListType': return new ListType(copyWrapperTypeOrTypeRef(source.ofType, destParent)!); case 'NonNullType': return new NonNullType(copyWrapperTypeOrTypeRef(source.ofType, destParent)! as NullableType); default: return destParent.type(source.name)!; } } function copyArgumentDefinitionInner

| DirectiveDefinition>({ source, dest, copyDirectiveApplications = true, cloneJoinDirectives, }: { source: ArgumentDefinition

, dest: ArgumentDefinition

, copyDirectiveApplications?: boolean, cloneJoinDirectives: boolean, }) { const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()) as InputType; dest.type = type; dest.defaultValue = source.defaultValue; if (copyDirectiveApplications) { copyAppliedDirectives(source, dest, cloneJoinDirectives); } dest.description = source.description; dest.sourceAST = source.sourceAST; } function copyDirectiveDefinitionInner( source: DirectiveDefinition, dest: DirectiveDefinition, copyDirectiveApplicationsInArguments: boolean = true, locationFilter?: (loc: DirectiveLocation) => boolean, ) { let locations = source.locations; if (locationFilter) { locations = locations.filter((loc) => locationFilter(loc)); } if (locations.length === 0) { return; } for (const arg of source.arguments()) { const type = copyWrapperTypeOrTypeRef(arg.type, dest.schema()); copyArgumentDefinitionInner({ source: arg, dest: dest.addArgument(arg.name, type as InputType), copyDirectiveApplications: copyDirectiveApplicationsInArguments, cloneJoinDirectives: true, }); } dest.repeatable = source.repeatable; dest.addLocations(...locations); dest.sourceAST = source.sourceAST; dest.description = source.description; } export function isFieldDefinition(elem: SchemaElement): elem is FieldDefinition { return elem instanceof FieldDefinition; } export function isElementNamedType(elem: SchemaElement): elem is NamedType { return elem instanceof BaseNamedType; }