From 9dca674868e470d2c012a1817c945a5b6152c010 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Fri, 4 Jun 2021 10:36:57 +0200 Subject: [PATCH 01/22] Setup @apollo/core module --- core-js/package.json | 28 ++++++++++++++++++++++++++++ core-js/tsconfig.json | 10 ++++++++++ lerna.json | 1 + package.json | 2 ++ 4 files changed, 41 insertions(+) create mode 100644 core-js/package.json create mode 100644 core-js/tsconfig.json diff --git a/core-js/package.json b/core-js/package.json new file mode 100644 index 000000000..bbad88f4e --- /dev/null +++ b/core-js/package.json @@ -0,0 +1,28 @@ +{ + "name": "@apollo/core", + "version": "0.1.0", + "description": "Apollo Federation core utilities", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/apollographql/federation.git", + "directory": "core-js/" + }, + "keywords": [ + "graphql", + "federation", + "apollo" + ], + "author": "Apollo ", + "license": "MIT", + "engines": { + "node": ">=12.13.0 <17.0" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "graphql": "^14.5.0 || ^15.0.0" + } +} diff --git a/core-js/tsconfig.json b/core-js/tsconfig.json new file mode 100644 index 000000000..1a1000c4d --- /dev/null +++ b/core-js/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["**/__tests__"], + "references": [] +} diff --git a/lerna.json b/lerna.json index 0d6f7dd5c..0d2ee3e5c 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,6 @@ { "packages": [ + "core-js", "federation-js", "federation-integration-testsuite-js", "gateway-js", diff --git a/package.json b/package.json index ac00f1ca6..99d8872f8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "npm": "7.x" }, "dependencies": { + "@apollo/core": "file:core-js", "@apollo/federation": "file:federation-js", "@apollo/gateway": "file:gateway-js", "@apollo/harmonizer": "file:harmonizer", @@ -79,6 +80,7 @@ }, "jest": { "projects": [ + "/core-js", "/federation-js", "/federation-integration-testsuite-js", "/gateway-js", From 606031c2901da6258b3d8ef8d68747614600222f Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Fri, 4 Jun 2021 12:20:48 +0200 Subject: [PATCH 02/22] Adds a more flexible GraphQLSchema replacement --- core-js/jest.config.js | 7 + core-js/package.json | 7 + core-js/src/__tests__/definitions.test.ts | 238 ++++ core-js/src/definitions.ts | 1561 +++++++++++++++++++++ core-js/src/federation.ts | 2 + core-js/src/print.ts | 139 ++ core-js/src/utils.ts | 14 + core-js/tsconfig.test.json | 8 + tsconfig.build-stage-03.json | 1 + 9 files changed, 1977 insertions(+) create mode 100644 core-js/jest.config.js create mode 100644 core-js/src/__tests__/definitions.test.ts create mode 100644 core-js/src/definitions.ts create mode 100644 core-js/src/federation.ts create mode 100644 core-js/src/print.ts create mode 100644 core-js/src/utils.ts create mode 100644 core-js/tsconfig.test.json diff --git a/core-js/jest.config.js b/core-js/jest.config.js new file mode 100644 index 000000000..c8cc716b9 --- /dev/null +++ b/core-js/jest.config.js @@ -0,0 +1,7 @@ +const baseConfig = require('../jest.config.base'); + +/** @typedef {import('ts-jest/dist/types')} */ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig +}; diff --git a/core-js/package.json b/core-js/package.json index bbad88f4e..8c08b161a 100644 --- a/core-js/package.json +++ b/core-js/package.json @@ -9,6 +9,9 @@ "url": "git+https://github.com/apollographql/federation.git", "directory": "core-js/" }, + "scripts": { + "test": "jest" + }, "keywords": [ "graphql", "federation", @@ -19,6 +22,10 @@ "engines": { "node": ">=12.13.0 <17.0" }, + "dependencies": { + "@types/jest": "^26.0.23", + "deep-equal": "^2.0.5" + }, "publishConfig": { "access": "public" }, diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts new file mode 100644 index 000000000..bf450bbb5 --- /dev/null +++ b/core-js/src/__tests__/definitions.test.ts @@ -0,0 +1,238 @@ +import { + AnyGraphQLDocument, + AnyObjectType, + AnySchemaElement, + AnyType, + GraphQLDocument, + MutableGraphQLDocument, + MutableObjectType, + MutableType, + ObjectType, + Type +} from '../../dist/definitions'; +import { + printDocument +} from '../../dist/print'; + +function expectObjectType(type: Type | MutableType | undefined): asserts type is ObjectType | MutableObjectType { + expect(type).toBeDefined(); + expect(type!.kind).toBe('ObjectType'); +} + +declare global { + namespace jest { + interface Matchers { + toHaveField(name: string, type?: AnyType): R; + toHaveDirective(name: string, args?: Map): R; + } + } +} + +expect.extend({ + toHaveField(parentType: AnyObjectType, name: string, type?: AnyType) { + const field = parentType.field(name); + if (!field) { + return { + message: () => `Cannot find field '${name}' in Object Type ${parentType} with fields [${[...parentType.fields.keys()]}]`, + pass: false + }; + } + if (field.name != name) { + return { + message: () => `Type ${parentType} has a field linked to name ${name} but that field name is actually ${field.name}`, + pass: false + }; + } + if (type && field.type() != type) { + return { + message: () => `Expected field ${parentType}.${name} to have type ${type} but got type ${field.type()}`, + pass: false + }; + } + return { + message: () => `Expected ${parentType} not to have field ${name} but it does (${field})`, + pass: true + } + }, + + toHaveDirective(element: AnySchemaElement, name: string, args?: Map) { + const directives = element.appliedDirective(name); + if (directives.length == 0) { + return { + message: () => `Cannot find directive @${name} applied to element ${element} (whose applied directives are [${element.appliedDirectives().join(', ')}]`, + pass: false + }; + } + if (!args) { + return { + message: () => `Expected directive @${name} to not be applied to ${element} but it is`, + pass: true + }; + } + + for (const directive of directives) { + if (directive.matchArguments(args)) { + return { + // Not 100% certain that message is correct but I don't think it's going to be used ... + message: () => `Expected directive ${directive.name} applied to ${element} to have arguments ${args} but got ${directive.arguments}`, + pass: true + }; + } + } + return { + message: () => `Element ${element} has application of directive @${name} but not with the requested arguments. Got applications: [${directives.join(', ')}]`, + pass: false + } + } +}); + +test('building a simple mutable document programatically and converting to immutable', () => { + const mutDoc = MutableGraphQLDocument.empty(); + const mutQueryType = mutDoc.schema.setRoot('query', mutDoc.addObjectType('Query')); + const mutTypeA = mutDoc.addObjectType('A'); + mutQueryType.addField('a', mutTypeA); + mutTypeA.addField('q', mutQueryType); + mutTypeA.applyDirective('inaccessible'); + mutTypeA.applyDirective('key', new Map([['fields', 'a']])); + + // Sanity check + expect(mutQueryType).toHaveField('a', mutTypeA); + expect(mutTypeA).toHaveField('q', mutQueryType); + expect(mutTypeA).toHaveDirective('inaccessible'); + expect(mutTypeA).toHaveDirective('key', new Map([['fields', 'a']])); + + const doc = mutDoc.toImmutable(); + const queryType = doc.type('Query'); + const typeA = doc.type('A'); + expect(queryType).toBe(doc.schema.root('query')); + expectObjectType(queryType); + expectObjectType(typeA); + expect(queryType).toHaveField('a', typeA); + expect(typeA).toHaveField('q', queryType); + expect(typeA).toHaveDirective('inaccessible'); + expect(typeA).toHaveDirective('key', new Map([['fields', 'a']])); +}); + +function parseAndValidateTestDocument(parser: (source: string) => Doc): Doc { + const sdl = +`schema { + query: MyQuery +} + +type A { + f1(x: Int @inaccessible): String + f2: String @inaccessible +} + +type MyQuery { + a: A + b: Int +}`; + const doc = parser(sdl); + + const queryType = doc.type('MyQuery')!; + const typeA = doc.type('A')!; + expectObjectType(queryType); + expectObjectType(typeA); + expect(doc.schema.root('query')).toBe(queryType); + expect(queryType).toHaveField('a', typeA); + const f2 = typeA.field('f2'); + expect(f2).toHaveDirective('inaccessible'); + expect(printDocument(doc)).toBe(sdl); + return doc; +} + + +test('parse immutable document', () => { + parseAndValidateTestDocument(GraphQLDocument.parse); +}); + +test('parse mutable document and modify', () => { + const doc = parseAndValidateTestDocument(MutableGraphQLDocument.parse); + const typeA = doc.type('A'); + expectObjectType(typeA); + expect(typeA).toHaveField('f1'); + typeA.field('f1')!.remove(); + expect(typeA).not.toHaveField('f1'); +}); + +test('removal of all directives of a document', () => { + const doc = MutableGraphQLDocument.parse(` + schema @foo { + query: Query + } + + type Query { + a(id: String @bar): A @inaccessible + } + + type A { + a1: String @foo + a2: [Int] + } + + type B @foo { + b: String @bar + } + + union U @foobar = A | B + `); + + for (const element of doc.allSchemaElement()) { + element.appliedDirectives().forEach(d => d.remove()); + } + + expect(printDocument(doc)).toBe( +`type A { + a1: String + a2: [Int] +} + +type B { + b: String +} + +type Query { + a(id: String): A +} + +union U = A | B`); +}); + +test('removal of all inacessible elements of a document', () => { + const doc = MutableGraphQLDocument.parse(` + schema @foo { + query: Query + } + + type Query { + a(id: String @bar, arg: Int @inaccessible): A + } + + type A { + a1: String @inaccessible + a2: [Int] + } + + type B @inaccessible { + b: String @bar + } + + union U @inaccessible = A | B + `); + + for (const element of doc.allSchemaElement()) { + if (element.appliedDirective('inaccessible').length > 0) { + element.remove(); + } + } + + expect(printDocument(doc)).toBe( +`type A { + a2: [Int] +} + +type Query { + a(id: String @bar): A +}`); +}); diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts new file mode 100644 index 000000000..bd0920ccb --- /dev/null +++ b/core-js/src/definitions.ts @@ -0,0 +1,1561 @@ +import { + ASTNode, + DefinitionNode, + DirectiveDefinitionNode, + DirectiveNode, + DocumentNode, + FieldDefinitionNode, + GraphQLError, + InputValueDefinitionNode, + parse, + SchemaDefinitionNode, + Source, + TypeNode, + valueFromASTUntyped, + ValueNode +} from "graphql"; +import { assert } from "./utils"; +import deepEqual from 'deep-equal'; + +export type QueryRoot = 'query'; +export type MutationRoot = 'mutation'; +export type SubscriptionRoot = 'subscription'; +export type SchemaRoot = QueryRoot | MutationRoot | SubscriptionRoot; + +export function defaultRootTypeName(root: SchemaRoot) { + return root.charAt(0).toUpperCase() + root.slice(1); +} + +type ImmutableWorld = { + detached: never, + document: GraphQLDocument, + schema: Schema, + schemaElement: SchemaElement, + type: Type, + namedType: NamedType, + objectType: ObjectType, + scalarType: ScalarType, + unionType: UnionType, + inputObjectType: InputObjectType, + inputType: InputType, + outputType: OutputType, + wrapperType: WrapperType, + listType: ListType, + fieldDefinition: FieldDefinition, + fieldArgumentDefinition: ArgumentDefinition, + inputFieldDefinition: InputFieldDefinition, + directiveDefinition: DirectiveDefinition, + directiveArgumentDefinition: ArgumentDefinition, + directive: Directive +} + +type MutableWorld = { + detached: undefined, + document: MutableGraphQLDocument, + schema: MutableSchema, + schemaElement: MutableSchemaElement, + type: MutableType, + namedType: MutableNamedType, + objectType: MutableObjectType, + scalarType: MutableScalarType, + unionType: MutableUnionType, + inputObjectType: MutableInputObjectType, + inputType: MutableInputType, + outputType: MutableOutputType, + wrapperType: MutableWrapperType, + listType: MutableListType, + fieldDefinition: MutableFieldDefinition, + fieldArgumentDefinition: MutableArgumentDefinition, + inputFieldDefinition: MutableInputFieldDefinition, + directiveDefinition: MutableDirectiveDefinition + directiveArgumentDefinition: MutableArgumentDefinition, + directive: MutableDirective +} + +type World = ImmutableWorld | MutableWorld; + +export type Type = InputType | OutputType; +export type NamedType = ScalarType | ObjectType | UnionType | InputObjectType; +export type OutputType = ScalarType | ObjectType | UnionType | ListType; +export type InputType = ScalarType | InputObjectType; +export type WrapperType = ListType; + +export type MutableType = MutableOutputType | MutableInputType; +export type MutableNamedType = MutableScalarType | MutableObjectType | MutableUnionType | MutableInputObjectType; +export type MutableOutputType = MutableScalarType | MutableObjectType | MutableUnionType | MutableListType; +export type MutableInputType = MutableScalarType | MutableInputObjectType; +export type MutableWrapperType = MutableListType; + +// Those exists to make it a bit easier to write code that work on both mutable and immutable variants, if one so wishes. +export type AnyGraphQLDocument = GraphQLDocument | MutableGraphQLDocument; +export type AnySchemaElement = SchemaElement | MutableSchemaElement; +export type AnyType = AnyOutputType | AnyInputType; +export type AnyNamedType = AnyScalarType | AnyObjectType | AnyUnionType | AnyInputObjectType; +export type AnyOutputType = AnyScalarType | AnyObjectType | AnyUnionType | AnyListType; +export type AnyInputType = AnyScalarType | AnyInputObjectType; +export type AnyWrapperType = AnyListType; +export type AnyScalarType = ScalarType | MutableScalarType; +export type AnyObjectType = ObjectType | MutableObjectType; +export type AnyUnionType = UnionType | MutableUnionType; +export type AnyInputObjectType = InputObjectType | MutableInputObjectType; +export type AnyListType = ListType | MutableListType; + +export type AnySchema = Schema | MutableSchema; +export type AnyDirectiveDefinition = DirectiveDefinition | MutableDirectiveDefinition; +export type AnyDirective = Directive | MutableDirective; +export type AnyFieldDefinition = FieldDefinition | MutableFieldDefinition; +export type AnyInputFieldDefinition = InputFieldDefinition | MutableInputFieldDefinition; +export type AnyFieldArgumentDefinition = ArgumentDefinition | MutableArgumentDefinition; +export type AnyDirectiveArgumentDefinition = ArgumentDefinition | MutableArgumentDefinition; +export type AnyArgumentDefinition = AnyFieldDefinition | AnyDirectiveDefinition; + +const builtInTypes = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; +const builtInDirectives = [ 'include', 'skip', 'deprecated', 'specifiedBy' ]; + +export function isNamedType(type: W['type']): type is W['namedType'] { + return type instanceof BaseNamedType; +} + +export function isWrapperType(type: W['type']): type is W['wrapperType'] { + return type.kind == 'ListType'; +} + +export function isBuiltInType(type: W['namedType']): boolean { + return builtInTypes.includes(type.name); +} + +export function isBuiltInDirective(directive: W['directiveDefinition']): boolean { + return builtInDirectives.includes(directive.name); +} + +export interface Named { + readonly name: string; +} + +function valueToString(v: any): string { + return JSON.stringify(v); +} + +function valueEquals(a: any, b: any): boolean { + return deepEqual(a, b); +} + +// TODO: make most of those a field since they are essentially a "property" of the element (document() excluded maybe). +export interface SchemaElement { + coordinate(): string; + document(): W['document'] | W['detached']; + parent(): W['schemaElement'] | W['document'] | W['detached']; + source(): ASTNode | undefined; + appliedDirectives(): readonly W['directive'][]; + appliedDirective(name: string): W['directive'][]; +} + +export interface MutableSchemaElement extends SchemaElement { + remove(): MutableSchemaElement[]; +} + +abstract class BaseElement

implements SchemaElement { + protected readonly _appliedDirectives: W['directive'][] = []; + + constructor( + protected _parent: P | W['detached'], + protected _source?: ASTNode + ) {} + + abstract coordinate(): string; + + document(): W['document'] | W['detached'] { + if (this._parent == undefined) { + return undefined; + } else if ('kind' in this._parent && this._parent.kind == 'Document') { + return this._parent as W['document']; + } else { + return (this._parent as W['schemaElement']).document(); + } + } + + parent(): P | W['detached'] { + return this._parent; + } + + setParent(parent: P) { + assert(!this._parent, "Cannot set parent of a non-detached element"); + this._parent = parent; + } + + source(): ASTNode | undefined { + return this._source; + } + + appliedDirectives(): readonly W['directive'][] { + return this._appliedDirectives; + } + + appliedDirective(name: string): W['directive'][] { + return this._appliedDirectives.filter(d => d.name == name); + } + + protected addAppliedDirective(directive: W['directive']): W['directive'] { + // TODO: should we dedup directives applications with the same arguments? + // TODO: also, should we reject directive applications for directives that are not declared (maybe do so in the Directive ctor + // and add a link to the definition)? + this._appliedDirectives.push(directive); + return directive; + } + + protected removeTypeReference(_: W['namedType']): void { + } +} + +abstract class BaseNamedElement

extends BaseElement implements Named { + constructor( + readonly name: string, + parent: P | W['detached'], + source?: ASTNode + ) { + super(parent, source); + } +} + +abstract class BaseNamedType extends BaseNamedElement { + protected readonly _referencers: Set = new Set(); + + protected constructor( + name: string, + document: W['document'] | W['detached'], + source?: ASTNode + ) { + super(name, document, source); + } + + coordinate(): string { + return this.name; + } + + *allChildrenElements(): Generator { + // Overriden by those types that do have chidrens + } + + private addReferencer(referencer: W['schemaElement']) { + assert(referencer, 'Referencer should exists'); + this._referencers.add(referencer); + } + + toString(): string { + return this.name; + } +} + +class BaseGraphQLDocument { + private _schema: W['schema'] | undefined = undefined; + protected readonly builtInTypes: Map = new Map(); + protected readonly typesMap: Map = new Map(); + protected readonly directivesMap: Map = new Map(); + + protected constructor() {} + + kind: 'Document' = 'Document'; + + // Used only through cheating the type system. + private setSchema(schema: W['schema']) { + this._schema = schema; + } + + private setBuiltIn(name: string, type: W['scalarType']) { + this.builtInTypes.set(name, type); + } + + get schema(): W['schema'] { + assert(this._schema, "Badly constructor document; doesn't have a schema"); + return this._schema; + } + + /** + * A map of all the types defined on this document _excluding_ the built-in types. + */ + get types(): ReadonlyMap { + return this.typesMap; + } + + /** + * The type of the provide name in this document if one is defined or if it is the name of a built-in. + */ + type(name: string): W['namedType'] | undefined { + const type = this.typesMap.get(name); + return type ? type : this.builtInTypes.get(name); + } + + intType(): W['scalarType'] { + return this.builtInTypes.get('Int')!; + } + + floatType(): W['scalarType'] { + return this.builtInTypes.get('Float')!; + } + + stringType(): W['scalarType'] { + return this.builtInTypes.get('String')!; + } + + booleanType(): W['scalarType'] { + return this.builtInTypes.get('Boolean')!; + } + + idType(): W['scalarType'] { + return this.builtInTypes.get('ID')!; + } + + get directives(): ReadonlyMap { + return this.directivesMap; + } + + directive(name: string): W['directiveDefinition'] | undefined { + return this.directivesMap.get(name); + } + + *allSchemaElement(): Generator { + if (this._schema) { + yield this._schema; + } + for (const type of this.types.values()) { + yield type; + yield* type.allChildrenElements(); + } + for (const directive of this.directives.values()) { + yield directive; + yield* directive.arguments().values(); + } + } +} + +export class GraphQLDocument extends BaseGraphQLDocument { + // Note that because typescript typesystem is structural, we need GraphQLDocument to some incompatible + // properties in GraphQLDocument that are not in MutableGraphQLDocument (not having MutableGraphQLDocument be a subclass + // of GraphQLDocument is not sufficient). This is the why of the 'mutable' field (the `toMutable` property + // also achieve this in practice, but we could want to add a toMutable() to MutableGraphQLDocument (that + // just return `this`) for some reason, so the field is a bit clearer/safer). + mutable: false = false; + + static parse(source: string | Source): GraphQLDocument { + return buildDocumentInternal(parse(source), Ctors.immutable); + } + + toMutable(): MutableGraphQLDocument { + return copy(this, Ctors.mutable); + } +} + +export class MutableGraphQLDocument extends BaseGraphQLDocument { + mutable: true = true; + + static empty(): MutableGraphQLDocument { + return Ctors.mutable.addSchema(Ctors.mutable.document()); + } + + static parse(source: string | Source): MutableGraphQLDocument { + return buildDocumentInternal(parse(source), Ctors.mutable); + } + + private ensureTypeNotFound(name: string) { + if (this.type(name)) { + throw new GraphQLError(`Type ${name} already exists in this document`); + } + } + + private addOrGetType(name: string, kind: string, adder: (n: string) => MutableNamedType) { + // Note: we don't use `this.type(name)` so that `addOrGetScalarType` always throws when called + // with the name of a scalar type. + const existing = this.typesMap.get(name); + if (existing) { + if (existing.kind == kind) { + return existing; + } + throw new GraphQLError(`Type ${name} already exists and is not an ${kind} (it is a ${existing.kind})`); + } + return adder(name); + } + + private addType(name: string, ctor: (n: string) => T): T { + this.ensureTypeNotFound(name); + const newType = ctor(name); + this.typesMap.set(newType.name, newType); + return newType; + } + + addOrGetObjectType(name: string): MutableObjectType { + return this.addOrGetType(name, 'ObjectType', n => this.addObjectType(n)) as MutableObjectType; + } + + addObjectType(name: string): MutableObjectType { + return this.addType(name, n => Ctors.mutable.createObjectType(n, this)); + } + + addOrGetScalarType(name: string): MutableScalarType { + return this.addOrGetType(name, 'ScalarType', n => this.addScalarType(n)) as MutableScalarType; + } + + addScalarType(name: string): MutableScalarType { + if (this.builtInTypes.has(name)) { + throw new GraphQLError(`Cannot add scalar type of name ${name} as it is a built-in type`); + } + return this.addType(name, n => Ctors.mutable.createScalarType(n, this)); + } + + addDirective(directive: MutableDirectiveDefinition) { + this.directivesMap.set(directive.name, directive); + } + + toImmutable(): GraphQLDocument { + return copy(this, Ctors.immutable); + } +} + +export class Schema extends BaseElement { + protected readonly rootsMap: Map = new Map(); + + protected constructor( + parent: W['document'] | W['detached'], + source?: ASTNode + ) { + super(parent, source); + } + + coordinate(): string { + return ''; + } + + kind: 'Schema' = 'Schema'; + + get roots(): ReadonlyMap { + return this.rootsMap; + } + + root(rootType: SchemaRoot): W['objectType'] | undefined { + return this.rootsMap.get(rootType); + } + + protected removeTypeReference(toRemove: W['namedType']): void { + for (const [root, type] of this.rootsMap) { + if (type == toRemove) { + this.rootsMap.delete(root); + } + } + } + + toString() { + return `schema[${[...this.rootsMap.keys()].join(', ')}]`; + } +} + +export class MutableSchema extends Schema implements MutableSchemaElement { + setRoot(rootType: SchemaRoot, objectType: MutableObjectType): MutableObjectType { + if (objectType.document() != this.document()) { + const attachement = objectType.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot use provided type ${objectType} for ${rootType} as it is not attached to this document (it is ${attachement})`); + } + this.rootsMap.set(rootType, objectType); + return objectType; + } + + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + remove(): MutableSchemaElement[] { + throw new Error('TODO'); + } +} + +export class ScalarType extends BaseNamedType { + kind: 'ScalarType' = 'ScalarType'; + + protected removeTypeReference(_: W['namedType']): void { + assert(false, "Scalar types can never reference other types"); + } +} + +export class MutableScalarType extends ScalarType implements MutableSchemaElement { + applyDirective(name: string, args?: Map): MutableDirective { + if (builtInTypes.includes(this.name)) { + throw Error(`Cannot apply directive to built-in type ${this.name}`); + } + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this type definition from its parent document. + * + * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * values, directives, etc... + * + * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * referening this type (and have thus now an undefined reference). + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + removeTypeDefinition(this, this._parent); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); + } + const toReturn = [... this._referencers].map(r => { + BaseElement.prototype['removeTypeReference'].call(r, this); + return r; + }); + this._referencers.clear(); + return toReturn; + } +} + +export class ObjectType extends BaseNamedType { + protected readonly fieldsMap: Map = new Map(); + + protected constructor( + name: string, + document: W['document'] | undefined, + source?: ASTNode + ) { + super(name, document, source); + } + + kind: 'ObjectType' = 'ObjectType'; + + get fields(): ReadonlyMap { + return this.fieldsMap; + } + + field(name: string): W['fieldDefinition'] | undefined { + return this.fieldsMap.get(name); + } + + *allChildrenElements(): Generator { + for (const field of this.fieldsMap.values()) { + yield field; + yield* field.arguments().values(); + } + } + + protected removeTypeReference(_: W['namedType']): void { + assert(false, "Object types can never reference other types directly (their field does)"); + } +} + +export class MutableObjectType extends ObjectType implements MutableSchemaElement { + addField(name: string, type: MutableOutputType): MutableFieldDefinition { + if (this.field(name)) { + throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); + } + if (type.document() != this.document()) { + const attachement = type.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this document (it is ${attachement})`); + } + const field = Ctors.mutable.createFieldDefinition(name, this, type); + this.fieldsMap.set(name, field); + return field; + } + + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this type definition from its parent document. + * + * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * values, directives, etc... + * + * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * referening this type (and have thus now an undefined reference). + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + removeTypeDefinition(this, this._parent); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); + } + for (const field of this.fieldsMap.values()) { + field.remove(); + } + const toReturn = [... this._referencers].map(r => { + BaseElement.prototype['removeTypeReference'].call(r, this); + return r; + }); + this._referencers.clear(); + return toReturn; + } +} + +export class UnionType extends BaseNamedType { + protected readonly typesList: W['objectType'][] = []; + + protected constructor( + name: string, + document: W['document'] | W['detached'], + source?: ASTNode + ) { + super(name, document, source); + } + + kind: 'UnionType' = 'UnionType'; + + get types(): readonly W['objectType'][] { + return this.typesList; + } + + protected removeTypeReference(type: W['namedType']): void { + const index = this.typesList.indexOf(type as W['objectType']); + if (index >= 0) { + this.typesList.splice(index, 1); + } + } +} + +export class MutableUnionType extends UnionType implements MutableSchemaElement { + addType(type: MutableObjectType): void { + if (type.document() != this.document()) { + const attachement = type.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot add provided type ${type} to union ${this} as it is not attached to this document (it is ${attachement})`); + } + if (!this.typesList.includes(type)) { + this.typesList.push(type); + } + } + + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this type definition from its parent document. + * + * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * values, directives, etc... + * + * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * referening this type (and have thus now an undefined reference). + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + removeTypeDefinition(this, this._parent); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); + } + this.typesList.splice(0, this.typesList.length); + const toReturn = [... this._referencers].map(r => { + BaseElement.prototype['removeTypeReference'].call(r, this); + return r; + }); + this._referencers.clear(); + return toReturn; + } +} + +export class InputObjectType extends BaseNamedType { + protected readonly fieldsMap: Map = new Map(); + + protected constructor( + name: string, + document: W['document'] | undefined, + source?: ASTNode + ) { + super(name, document, source); + } + + kind: 'InputObjectType' = 'InputObjectType'; + + get fields(): ReadonlyMap { + return this.fieldsMap; + } + + field(name: string): W['inputFieldDefinition'] | undefined { + return this.fieldsMap.get(name); + } + + *allChildrenElements(): Generator { + yield* this.fieldsMap.values(); + } + + protected removeTypeReference(_: W['namedType']): void { + assert(false, "Input object types can never reference other types directly (their field does)"); + } +} + +export class MutableInputObjectType extends InputObjectType implements MutableSchemaElement { + addField(name: string, type: MutableInputType): MutableInputFieldDefinition { + if (this.field(name)) { + throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); + } + if (type.document() != this.document()) { + const attachement = type.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this document (it is ${attachement})`); + } + const field = Ctors.mutable.createInputFieldDefinition(name, this, type); + this.fieldsMap.set(name, field); + return field; + } + + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this type definition from its parent document. + * + * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * values, directives, etc... + * + * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * referening this type (and have thus now an undefined reference). + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + removeTypeDefinition(this, this._parent); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); + } + for (const field of this.fieldsMap.values()) { + field.remove(); + } + const toReturn = [... this._referencers].map(r => { + BaseElement.prototype['removeTypeReference'].call(r, this); + return r; + }); + this._referencers.clear(); + return toReturn; + } +} + +export class ListType { + protected constructor(protected _type: T) {} + + kind: 'ListType' = 'ListType'; + + document(): W['document'] { + return this.baseType().document() as W['document']; + } + + ofType(): T { + return this._type; + } + + baseType(): W['namedType'] { + return isWrapperType(this._type) ? this._type.baseType() : this._type as W['namedType']; + } + + toString(): string { + return `[${this.ofType()}]`; + } +} + +export class MutableListType extends ListType {} + +export class FieldDefinition extends BaseNamedElement { + protected readonly _args: Map = new Map(); + + protected constructor( + name: string, + parent: W['objectType'] | W['detached'], + protected _type: W['outputType'] | W['detached'], + source?: ASTNode + ) { + super(name, parent, source); + } + + kind: 'FieldDefinition' = 'FieldDefinition'; + + coordinate(): string { + const parent = this.parent(); + return `${parent == undefined ? '' : parent.coordinate()}.${this.name}`; + } + + type(): W['outputType'] | W['detached'] { + return this._type; + } + + arguments(): ReadonlyMap { + return this._args; + } + + argument(name: string): W['fieldArgumentDefinition'] | undefined { + return this._args.get(name); + } + + protected removeTypeReference(type: W['namedType']): void { + if (this._type == type) { + this._type = undefined; + } + } + + toString(): string { + const args = this._args.size == 0 + ? "" + : '(' + [...this._args.values()].map(arg => arg.toString()).join(', ') + ')'; + return `${this.name}${args}: ${this.type()}`; + } +} + +export class MutableFieldDefinition extends FieldDefinition implements MutableSchemaElement { + setType(type: MutableOutputType): MutableFieldDefinition { + if (!this.document()) { + // Let's not allow manipulating detached elements too much as this could make our lives harder. + throw new GraphQLError(`Cannot set the type of field ${this.name} as it is detached`); + } + if (type.document() != this.document()) { + const attachement = type.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot set provided type ${type} to field ${this.name} as it is not attached to this document (it is ${attachement})`); + } + this._type = type; + return this; + } + + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this field definition from its parent type. + * + * After calling this method, this field definition will be "detached": it wil have no parent, document, type, + * arguments or directives. + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + (this._parent.fields as Map).delete(this.name); + // We "clean" all the attributes of the object. This is because we mean detached element to be essentially + // dead and meant to be GCed and this ensure we don't prevent that for no good reason. + this._parent = undefined; + this._type = undefined; + for (const arg of this._args.values()) { + arg.remove(); + } + // Fields have nothing that can reference them outside of their parents + return []; + } +} + +export class InputFieldDefinition extends BaseNamedElement { + protected constructor( + name: string, + parent: W['inputObjectType'] | W['detached'], + protected _type: W['inputType'] | W['detached'], + source?: ASTNode + ) { + super(name, parent, source); + } + + coordinate(): string { + const parent = this.parent(); + return `${parent == undefined ? '' : parent.coordinate()}.${this.name}`; + } + + kind: 'InputFieldDefinition' = 'InputFieldDefinition'; + + type(): W['inputType'] | W['detached'] { + return this._type; + } + + protected removeTypeReference(type: W['namedType']): void { + if (this._type == type) { + this._type = undefined; + } + } + + toString(): string { + return `${this.name}: ${this.type()}`; + } +} + +export class MutableInputFieldDefinition extends InputFieldDefinition implements MutableSchemaElement { + setType(type: MutableInputType): MutableInputFieldDefinition { + if (!this.document()) { + // Let's not allow manipulating detached elements too much as this could make our lives harder. + throw new GraphQLError(`Cannot set the type of input field ${this.name} as it is detached`); + } + if (type.document() != this.document()) { + const attachement = type.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot set provided type ${type} to input field ${this.name} as it is not attached to this document (it is ${attachement})`); + } + this._type = type; + return this; + } + + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this field definition from its parent type. + * + * After calling this method, this field definition will be "detached": it wil have no parent, document, type, + * arguments or directives. + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + (this._parent.fields as Map).delete(this.name); + // We "clean" all the attributes of the object. This is because we mean detached element to be essentially + // dead and meant to be GCed and this ensure we don't prevent that for no good reason. + this._parent = undefined; + this._type = undefined; + // Fields have nothing that can reference them outside of their parents + return []; + } +} + +export class ArgumentDefinition

extends BaseNamedElement { + protected constructor( + name: string, + parent: P | W['detached'], + protected _type: W['inputType'] | W['detached'], + protected _defaultValue: any, + source?: ASTNode + ) { + super(name, parent, source); + } + + kind: 'ArgumentDefinition' = 'ArgumentDefinition'; + + coordinate(): string { + const parent = this.parent(); + return `${parent == undefined ? '' : parent.coordinate()}(${this.name}:)`; + } + + type(): W['inputType'] | W['detached'] { + return this._type; + } + + defaultValue(): any { + return this._defaultValue; + } + + protected removeTypeReference(type: W['namedType']): void { + if (this._type == type) { + this._type = undefined; + } + } + + toString() { + const defaultStr = this._defaultValue == undefined ? "" : ` = ${this._defaultValue}`; + return `${this.name}: ${this._type}${defaultStr}`; + } +} + +export class MutableArgumentDefinition

extends ArgumentDefinition implements MutableSchemaElement { + applyDirective(name: string, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + } + + /** + * Removes this argument definition from its parent element (field or directive). + * + * After calling this method, this argument definition will be "detached": it wil have no parent, document, type, + * default value or directives. + */ + remove(): MutableSchemaElement[] { + if (!this._parent) { + return []; + } + (this._parent.arguments() as Map).delete(this.name); + // We "clean" all the attributes of the object. This is because we mean detached element to be essentially + // dead and meant to be GCed and this ensure we don't prevent that for no good reason. + this._parent = undefined; + this._type = undefined; + this._defaultValue = undefined; + return []; + } +} + +export class DirectiveDefinition extends BaseNamedElement { + protected readonly _args: Map = new Map(); + + protected constructor( + name: string, + document: W['document'] | W['detached'], + source?: ASTNode + ) { + super(name, document, source); + } + + kind: 'Directive' = 'Directive'; + + coordinate(): string { + throw new Error('TODO'); + } + + arguments(): ReadonlyMap { + return this._args; + } + + argument(name: string): W['directiveArgumentDefinition'] | undefined { + return this._args.get(name); + } + + protected removeTypeReference(_: W['namedType']): void { + assert(false, "Directive definitions can never reference other types directly (their arguments might)"); + } + + toString(): string { + return this.name; + } +} + +export class MutableDirectiveDefinition extends DirectiveDefinition implements MutableSchemaElement { + addArgument(name: string, type: MutableInputType, defaultValue?: any): MutableArgumentDefinition { + if (!this.document()) { + // Let's not allow manipulating detached elements too much as this could make our lives harder. + throw new GraphQLError(`Cannot add argument to directive definition ${this.name} as it is detached`); + } + if (type.document() != this.document()) { + const attachement = type.document() ? 'attached to another document' : 'detached'; + throw new GraphQLError(`Cannot use type ${type} for argument of directive definition ${this.name} as it is not attached to this document (it is ${attachement})`); + } + const newArg = Ctors.mutable.createDirectiveArgumentDefinition(name, this, type, defaultValue); + this._args.set(name, newArg); + return newArg; + } + + remove(): MutableSchemaElement[] { + throw new Error('TODO'); + } +} + +export class Directive implements Named { + protected constructor( + readonly name: string, + protected _parent: W['schemaElement'] | W['detached'], + protected _args: Map, + readonly source?: ASTNode + ) { + } + + document(): W['document'] | W['detached'] { + return this._parent?.document(); + } + + parent(): W['schemaElement'] | W['detached'] { + return this._parent; + } + + definition(): W['directiveDefinition'] | W['detached'] { + const doc = this.document(); + return doc?.directive(this.name); + } + + get arguments() : ReadonlyMap { + return this._args; + } + + argument(name: string): any { + return this._args.get(name); + } + + matchArguments(expectedArgs: Map): boolean { + if (this._args.size !== expectedArgs.size) { + return false; + } + for (var [key, val] of this._args) { + const expectedVal = expectedArgs.get(key); + // In cases of an undefined value, make sure the key actually exists on the object so there are no false positives + if (!valueEquals(expectedVal, val) || (expectedVal === undefined && !expectedArgs.has(key))) { + return false; + } + } + return true; + } + + toString(): string { + const args = this._args.size == 0 ? '' : '(' + [...this._args.entries()].map(([n, v]) => `${n}: ${valueToString(v)}`).join(', ') + ')'; + return `@${this.name}${args}`; + } +} + +export class MutableDirective extends Directive { + /** + * 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; + } + const parentDirectives = this._parent.appliedDirectives() as MutableDirective[]; + const index = parentDirectives.indexOf(this); + assert(index >= 0, `Directive ${this} lists ${this._parent} as parent, but that parent doesn't list it as applied directive`); + parentDirectives.splice(index, 1); + this._parent = undefined; + return true; + } +} + +class Ctors { + // The definitions below are a hack to work around that typescript does not have "module" visibility for class constructors. + // Meaning, we don't want the constructors below to be exposed (because it would be way too easy to break some of the class + // invariants if using them, and more generally we don't want users to care about that level of detail), so all those ctors + // are protected, but we still need to access them here, hence the `Function.prototype` hack. + // Note: this is fairly well contained so manageable but certainly ugly and a bit error-prone, so if someone knowns a better way? + static immutable = new Ctors( + () => new (Function.prototype.bind.call(GraphQLDocument, null)), + (parent, source) => new (Function.prototype.bind.call(Schema, null, parent, source)), + (name, doc, source) => new (Function.prototype.bind.call(ScalarType, null, name, doc, source)), + (name, doc, source) => new (Function.prototype.bind.call(ObjectType, null, name, doc, source)), + (name, doc, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, source)), + (name, doc, source) => new (Function.prototype.bind.call(InputObjectType, null, name, doc, source)), + (type) => new (Function.prototype.bind.call(ListType, null, type)), + (name, parent, type, source) => new (Function.prototype.bind.call(FieldDefinition, null, name, parent, type, source)), + (name, parent, type, source) => new (Function.prototype.bind.call(InputFieldDefinition, null, name, parent, type, source)), + (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), + (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), + (name, parent, source) => new (Function.prototype.bind.call(DirectiveDefinition, null, name, parent, source)), + (name, parent, args, source) => new (Function.prototype.bind.call(Directive, null, name, parent, args, source)), + (v) => { + if (v == undefined) + // TODO: Better error; maybe pass a string to include so the message is more helpful. + throw new Error("Invalid detached value"); + return v; + } + ); + + static mutable = new Ctors( + () => new (Function.prototype.bind.call(MutableGraphQLDocument, null)), + (parent, source) => new (Function.prototype.bind.call(MutableSchema, null, parent, source)), + (name, doc, source) => new (Function.prototype.bind.call(MutableScalarType, null, name, doc, source)), + (name, doc, source) => new (Function.prototype.bind.call(MutableObjectType, null, name, doc, source)), + (name, doc, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, source)), + (name, doc, source) => new (Function.prototype.bind.call(MutableInputObjectType, null, name, doc, source)), + (type) => new (Function.prototype.bind.call(MutableListType, null, type)), + (name, parent, type, source) => new (Function.prototype.bind.call(MutableFieldDefinition, null, name, parent, type, source)), + (name, parent, type, source) => new (Function.prototype.bind.call(MutableInputFieldDefinition, null, name, parent, type, source)), + (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), + (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), + (name, parent, source) => new (Function.prototype.bind.call(MutableDirectiveDefinition, null, name, parent, source)), + (name, parent, args, source) => new (Function.prototype.bind.call(MutableDirective, null, name, parent, args, source)), + (v) => v + ); + + constructor( + private readonly createDocument: () => W['document'], + private readonly createSchema: (parent: W['document'] | W['detached'], source?: ASTNode) => W['schema'], + readonly createScalarType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['scalarType'], + readonly createObjectType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['objectType'], + readonly createUnionType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['unionType'], + readonly createInputObjectType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['inputObjectType'], + readonly createList: (type: T) => W['listType'], + readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], + readonly createInputFieldDefinition: (name: string, parent: W['inputObjectType'] | W['detached'], type: W['inputType'], source?: ASTNode) => W['inputFieldDefinition'], + readonly createFieldArgumentDefinition: (name: string, parent: W['fieldDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['fieldArgumentDefinition'], + readonly createDirectiveArgumentDefinition: (name: string, parent: W['directiveDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['directiveArgumentDefinition'], + readonly createDirectiveDefinition: (name: string, parent: W['document'] | W['detached'], source?: ASTNode) => W['directiveDefinition'], + readonly createDirective: (name: string, parent: W['schemaElement'] | W['detached'], args: Map, source?: ASTNode) => W['directive'], + readonly checkDetached: (v: T | undefined) => T | W['detached'] + ) { + } + + document(): W['document'] { + const doc = this.createDocument(); + for (const builtIn of builtInTypes) { + BaseGraphQLDocument.prototype['setBuiltIn'].call(doc, builtIn, this.createScalarType(builtIn, doc)); + } + return doc; + } + + addSchema(document: W['document'], source?: ASTNode): W['document'] { + const schema = this.createSchema(document, source); + BaseGraphQLDocument.prototype['setSchema'].call(document, schema); + return document; + } + + createNamedType(kind: string, name: string, document: W['document'], source?: ASTNode): W['namedType'] { + switch (kind) { + case 'ScalarType': + return this.createScalarType(name, document, source); + case 'ObjectType': + return this.createObjectType(name, document, source); + case 'UnionType': + return this.createUnionType(name, document, source); + case 'InputObjectType': + return this.createInputObjectType(name, document, source); + default: + assert(false, "Missing branch for type " + kind); + } + } +} + +function addTypeDefinition(namedType: W['namedType'], document: W['document']) { + (document.types as Map).set(namedType.name, namedType); +} + +function removeTypeDefinition(namedType: W['namedType'], document: W['document']) { + (document.types as Map).delete(namedType.name); +} + +function addDirectiveDefinition(definition: W['directiveDefinition'], document: W['document']) { + (document.directives as Map).set(definition.name, definition); +} + +function addRoot(root: SchemaRoot, typeName: string, schema: W['schema']) { + const type = schema.document()!.type(typeName)! as W['objectType']; + (schema.roots as Map).set(root, type); + addReferencerToType(schema, type); +} + +function addFieldArg(arg: W['fieldArgumentDefinition'], field: W['fieldDefinition']) { + (field.arguments() as Map).set(arg.name, arg); +} + +function addDirectiveArg(arg: W['directiveArgumentDefinition'], directive: W['directiveDefinition']) { + (directive.arguments() as Map).set(arg.name, arg); +} + +function addField(field: W['fieldDefinition'] | W['inputFieldDefinition'], objectType: W['objectType'] | W['inputObjectType']) { + (objectType.fields as Map).set(field.name, field); +} + +function addTypeToUnion(typeName: string, unionType: W['unionType']) { + const type = unionType.document()!.type(typeName)! as W['objectType']; + (unionType.types as W['objectType'][]).push(type); + addReferencerToType(unionType, type); +} + +function addReferencerToType(referencer: W['schemaElement'], type: W['type']) { + switch (type.kind) { + case 'ListType': + addReferencerToType(referencer, (type as W['listType']).baseType()); + break; + default: + BaseNamedType.prototype['addReferencer'].call(type, referencer); + break; + } +} + +function buildValue(value?: ValueNode): any { + // TODO: Should we rewrite a version of valueFromAST instead of using valueFromASTUntyped? Afaict, what we're missing out on is + // 1) coercions, which concretely, means: + // - for enums, we get strings + // - for int, we don't get the validation that it should be a 32bit value. + // - for ID, which accepts strings and int, we don't get int converted to string. + // - for floats, we get either int or float, we don't get int converted to float. + // - we don't get any custom coercion (but neither is buildSchema in graphQL-js anyway). + // 2) type validation. + return value ? valueFromASTUntyped(value) : undefined; +} + +function buildDocumentInternal(documentNode: DocumentNode, ctors: Ctors): W['document'] { + const doc = ctors.document(); + buildNamedTypeShallow(documentNode, doc, ctors); + for (const definitionNode of documentNode.definitions) { + switch (definitionNode.kind) { + case 'OperationDefinition': + case 'FragmentDefinition': + throw new GraphQLError("Invalid executable definition found while building document", definitionNode); + case 'SchemaDefinition': + buildSchema(definitionNode, doc, ctors); + break; + case 'ScalarTypeDefinition': + case 'ObjectTypeDefinition': + case 'InterfaceTypeDefinition': + case 'UnionTypeDefinition': + case 'EnumTypeDefinition': + case 'InputObjectTypeDefinition': + buildNamedTypeInner(definitionNode, doc.type(definitionNode.name.value)!, ctors); + break; + case 'DirectiveDefinition': + addDirectiveDefinition(buildDirectiveDefinition(definitionNode, doc, ctors), doc); + break; + case 'SchemaExtension': + case 'ScalarTypeExtension': + case 'ObjectTypeExtension': + case 'InterfaceTypeExtension': + case 'UnionTypeExtension': + case 'EnumTypeExtension': + case 'InputObjectTypeExtension': + throw new Error("Extensions are a TODO"); + } + } + return doc; +} + +function buildNamedTypeShallow(documentNode: DocumentNode, document: W['document'], ctors: Ctors) { + for (const definitionNode of documentNode.definitions) { + switch (definitionNode.kind) { + case 'ScalarTypeDefinition': + case 'ObjectTypeDefinition': + case 'InterfaceTypeDefinition': + case 'UnionTypeDefinition': + case 'EnumTypeDefinition': + case 'InputObjectTypeDefinition': + addTypeDefinition(ctors.createNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value, document, definitionNode), document); + break; + case 'SchemaExtension': + case 'ScalarTypeExtension': + case 'ObjectTypeExtension': + case 'InterfaceTypeExtension': + case 'UnionTypeExtension': + case 'EnumTypeExtension': + case 'InputObjectTypeExtension': + throw new Error("Extensions are a TODO"); + } + } +} + +type NodeWithDirectives = {directives?: ReadonlyArray}; + +function withoutTrailingDefinition(str: string): string { + return str.slice(0, str.length - 'Definition'.length); +} + +function buildSchema(schemaNode: SchemaDefinitionNode, document: W['document'], ctors: Ctors) { + ctors.addSchema(document, schemaNode); + buildAppliedDirectives(schemaNode, document.schema, ctors); + for (const opTypeNode of schemaNode.operationTypes) { + addRoot(opTypeNode.operation, opTypeNode.type.name.value, document.schema); + } +} + +function buildAppliedDirectives(elementNode: NodeWithDirectives, element: W['schemaElement'], ctors: Ctors) { + for (const directive of elementNode.directives ?? []) { + BaseElement.prototype['addAppliedDirective'].call(element, buildDirective(directive, element, ctors)); + } +} + +function buildDirective(directiveNode: DirectiveNode, element: W['schemaElement'], ctors: Ctors): W['directive'] { + const args = new Map(); + for (const argNode of directiveNode.arguments ?? []) { + args.set(argNode.name.value, buildValue(argNode.value)); + } + return ctors.createDirective(directiveNode.name.value, element, args, directiveNode); +} + +function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives, type: W['namedType'], ctors: Ctors) { + buildAppliedDirectives(definitionNode, type, ctors); + switch (definitionNode.kind) { + case 'ObjectTypeDefinition': + const objectType = type as W['objectType']; + for (const fieldNode of definitionNode.fields ?? []) { + addField(buildFieldDefinition(fieldNode, objectType, ctors), objectType); + } + break; + case 'InterfaceTypeDefinition': + throw new Error("TODO"); + case 'UnionTypeDefinition': + const unionType = type as W['unionType']; + for (const namedType of definitionNode.types ?? []) { + addTypeToUnion(namedType.name.value, unionType); + } + break; + case 'EnumTypeDefinition': + throw new Error("TODO"); + case 'InputObjectTypeDefinition': + const inputObjectType = type as W['inputObjectType']; + for (const fieldNode of definitionNode.fields ?? []) { + addField(buildInputFieldDefinition(fieldNode, inputObjectType, ctors), inputObjectType); + } + break; + } +} + +function buildFieldDefinition(fieldNode: FieldDefinitionNode, parentType: W['objectType'], ctors: Ctors): W['fieldDefinition'] { + const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.document()!, ctors) as W['outputType']; + const builtField = ctors.createFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); + buildAppliedDirectives(fieldNode, builtField, ctors); + for (const inputValueDef of fieldNode.arguments ?? []) { + addFieldArg(buildFieldArgumentDefinition(inputValueDef, builtField, ctors), builtField); + } + addReferencerToType(builtField, type); + return builtField; +} + +function buildWrapperTypeOrTypeRef(typeNode: TypeNode, document: W['document'], ctors: Ctors): W['type'] { + switch (typeNode.kind) { + case 'ListType': + return ctors.createList(buildWrapperTypeOrTypeRef(typeNode.type, document, ctors)); + case 'NonNullType': + throw new Error('TODO'); + default: + return document.type(typeNode.name.value)!; + } +} + +function buildFieldArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['fieldDefinition'], ctors: Ctors): W['fieldArgumentDefinition'] { + const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.document()!, ctors) as W['inputType']; + const built = ctors.createFieldArgumentDefinition(inputNode.name.value, parent, type, buildValue(inputNode.defaultValue), inputNode); + buildAppliedDirectives(inputNode, built, ctors); + addReferencerToType(built, type); + return built; +} + +function buildInputFieldDefinition(fieldNode: InputValueDefinitionNode, parentType: W['inputObjectType'], ctors: Ctors): W['inputFieldDefinition'] { + const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.document()!, ctors) as W['inputType']; + const builtField = ctors.createInputFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); + buildAppliedDirectives(fieldNode, builtField, ctors); + addReferencerToType(builtField, type); + return builtField; +} + +function buildDirectiveDefinition(directiveNode: DirectiveDefinitionNode, parent: W['document'], ctors: Ctors): W['directiveDefinition'] { + const builtDirective = ctors.createDirectiveDefinition(directiveNode.name.value, parent, directiveNode); + for (const inputValueDef of directiveNode.arguments ?? []) { + addDirectiveArg(buildDirectiveArgumentDefinition(inputValueDef, builtDirective, ctors), builtDirective); + } + return builtDirective; +} + +function buildDirectiveArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['directiveDefinition'], ctors: Ctors): W['directiveArgumentDefinition'] { + const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.document()!, ctors) as W['inputType']; + const built = ctors.createDirectiveArgumentDefinition(inputNode.name.value, parent, type, buildValue(inputNode.defaultValue), inputNode); + buildAppliedDirectives(inputNode, built, ctors); + addReferencerToType(built, type); + return built; +} + +function copy(source: WS['document'], destCtors: Ctors): WD['document'] { + const doc = destCtors.addSchema(destCtors.document(), source.schema.source()); + for (const type of source.types.values()) { + addTypeDefinition(copyNamedTypeShallow(type, doc, destCtors), doc); + } + copySchemaInner(source.schema, doc.schema, destCtors); + for (const type of source.types.values()) { + copyNamedTypeInner(type, doc.type(type.name)!, destCtors); + } + for (const directive of source.directives.values()) { + addDirectiveDefinition(copyDirectiveDefinition(directive, doc, destCtors), doc); + } + return doc; +} + +function copySchemaInner(source: WS['schema'], dest: WD['schema'], destCtors: Ctors) { + for (const [root, type] of source.roots.entries()) { + addRoot(root, type.name, dest); + } + copyAppliedDirectives(source, dest, destCtors); +} + +function copyAppliedDirectives(source: WS['schemaElement'], dest: WD['schemaElement'], destCtors: Ctors) { + for (const directive of source.appliedDirectives()) { + BaseElement.prototype['addAppliedDirective'].call(dest, copyDirective(directive, dest, destCtors)); + } +} + +function copyDirective(source: WS['directive'], parentDest: WD['schemaElement'], destCtors: Ctors): WD['directive'] { + const args = new Map(); + for (const [name, value] of source.arguments.entries()) { + args.set(name, value); + } + return destCtors.createDirective(source.name, parentDest, args, source.source); +} + +// Because types can refer to one another (through fields or directive applications), we first create a shallow copy of +// all types, and then copy fields (see below) assuming that the type "shell" exists. +function copyNamedTypeShallow(source: WS['namedType'], document: WD['document'], destCtors: Ctors): WD['namedType'] { + return destCtors.createNamedType(source.kind, source.name, document, source.source()); +} + +function copyNamedTypeInner(source: WS['namedType'], dest: WD['namedType'], destCtors: Ctors) { + copyAppliedDirectives(source, dest, destCtors); + switch (source.kind) { + case 'ObjectType': + const sourceObjectType = source as WS['objectType']; + const destObjectType = dest as WD['objectType']; + for (const field of sourceObjectType.fields.values()) { + addField(copyFieldDefinition(field, destObjectType, destCtors), destObjectType); + } + break; + case 'UnionType': + const sourceUnionType = source as WS['unionType']; + const destUnionType = dest as WD['unionType']; + for (const type of sourceUnionType.types) { + addTypeToUnion(type.name, destUnionType); + } + break; + case 'InputObjectType': + const sourceInputObjectType = source as WS['inputObjectType']; + const destInputObjectType = dest as WD['inputObjectType']; + for (const field of sourceInputObjectType.fields.values()) { + addField(copyInputFieldDefinition(field, destInputObjectType, destCtors), destInputObjectType); + } + } +} + +function copyFieldDefinition(source: WS['fieldDefinition'], destParent: WD['objectType'], destCtors: Ctors): WD['fieldDefinition'] { + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as WD['outputType']; + const copiedField = destCtors.createFieldDefinition(source.name, destParent, type, source.source()); + copyAppliedDirectives(source, copiedField, destCtors); + for (const sourceArg of source.arguments().values()) { + addFieldArg(copyFieldArgumentDefinition(sourceArg, copiedField, destCtors), copiedField); + } + addReferencerToType(copiedField, type); + return copiedField; +} + +function copyInputFieldDefinition(source: WS['inputFieldDefinition'], destParent: WD['inputObjectType'], destCtors: Ctors): WD['inputFieldDefinition'] { + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as WD['inputType']; + const copied = destCtors.createInputFieldDefinition(source.name, destParent, type, source.source()); + copyAppliedDirectives(source, copied, destCtors); + addReferencerToType(copied, type); + return copied; +} + +function copyWrapperTypeOrTypeRef(source: WS['type'] | WS['detached'], destParent: WD['document'], destCtors: Ctors): WD['type'] | WD['detached'] { + if (source == undefined) { + return destCtors.checkDetached(undefined); + } + switch (source.kind) { + case 'ListType': + return destCtors.createList(copyWrapperTypeOrTypeRef((source as WS['listType']).ofType(), destParent, destCtors) as WD['type']); + default: + return destParent.type((source as WS['namedType']).name)!; + } +} + +function copyFieldArgumentDefinition(source: WS['fieldArgumentDefinition'], destParent: WD['fieldDefinition'], destCtors: Ctors): WD['fieldArgumentDefinition'] { + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as WD['inputType']; + const copied = destCtors.createFieldArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source()); + copyAppliedDirectives(source, copied, destCtors); + addReferencerToType(copied, type); + return copied; +} + +function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['document'], destCtors: Ctors): WD['directiveDefinition'] { + const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, source.source()); + for (const sourceArg of source.arguments().values()) { + addDirectiveArg(copyDirectiveArgumentDefinition(sourceArg, copiedDirective, destCtors), copiedDirective); + } + return copiedDirective; +} +function copyDirectiveArgumentDefinition(source: WS['directiveArgumentDefinition'], destParent: WD['directiveDefinition'], destCtors: Ctors): WD['directiveArgumentDefinition'] { + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as InputType; + const copied = destCtors.createDirectiveArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source()); + copyAppliedDirectives(source, copied, destCtors); + addReferencerToType(copied, type); + return copied; +} diff --git a/core-js/src/federation.ts b/core-js/src/federation.ts new file mode 100644 index 000000000..905272627 --- /dev/null +++ b/core-js/src/federation.ts @@ -0,0 +1,2 @@ +export const federationMachineryTypesNames = [ '_Entity', '_Service', '_Any' ]; +export const federationDirectivesNames = [ 'key', 'extends', 'external', 'requires', 'provides' ]; diff --git a/core-js/src/print.ts b/core-js/src/print.ts new file mode 100644 index 000000000..ed08a1ce2 --- /dev/null +++ b/core-js/src/print.ts @@ -0,0 +1,139 @@ +import { + AnyDirective, + AnyDirectiveDefinition, + AnyFieldDefinition, + AnyGraphQLDocument, + AnyInputFieldDefinition, + AnyInputObjectType, + AnyNamedType, + AnyObjectType, + AnyScalarType, + AnySchema, + AnySchemaElement, + AnyUnionType, + defaultRootTypeName, + isBuiltInDirective, + isBuiltInType +} from "./definitions"; +import { federationMachineryTypesNames, federationDirectivesNames } from "./federation"; + +const indent = " "; // Could be made an option at some point + +const defaultTypeFilter = (type: AnyNamedType) => ( + !isBuiltInType(type) && + !federationMachineryTypesNames.includes(type.name) +); + +const defaultDirectiveFilter = (directive: AnyDirectiveDefinition) => ( + !isBuiltInDirective(directive) && + !federationDirectivesNames.includes(directive.name) +); + +export function printDocument(document: AnyGraphQLDocument): string { + return printFilteredDocument(document, defaultTypeFilter, defaultDirectiveFilter); +} + +function printFilteredDocument( + document: AnyGraphQLDocument, + typeFilter: (type: AnyNamedType) => boolean, + directiveFilter: (type: AnyDirectiveDefinition) => boolean +): string { + const directives = [...document.directives.values()].filter(directiveFilter); + const types = [...document.types.values()] + .sort((type1, type2) => type1.name.localeCompare(type2.name)) + .filter(typeFilter); + return ( + [printSchema(document.schema)] + .concat( + directives.map(directive => printDirectiveDefinition(directive)), + types.map(type => printTypeDefinition(type)), + ) + .filter(Boolean) + .join('\n\n') + ); +} + +function printSchema(schema: AnySchema): string | undefined { + if (isSchemaOfCommonNames(schema)) { + return; + } + const rootEntries = [...schema.roots.entries()].map(([root, type]) => `${indent}${root}: ${type}`); + return `schema {\n${rootEntries.join('\n')}\n}`; +} + +/** + * GraphQL schema define root types for each type of operation. These types are + * the same as any other type and can be named in any manner, however there is + * a common naming convention: + * + * schema { + * query: Query + * mutation: Mutation + * } + * + * When using this naming convention, the schema description can be omitted. + */ +function isSchemaOfCommonNames(schema: AnySchema): boolean { + for (const [root, type] of schema.roots) { + if (type.name != defaultRootTypeName(root)) { + return false; + } + } + return true; +} + +export function printTypeDefinition(type: AnyNamedType): string { + switch (type.kind) { + case 'ScalarType': return printScalarType(type); + case 'ObjectType': return printObjectType(type); + case 'UnionType': return printUnionType(type); + case 'InputObjectType': return printInputObjectType(type); + } +} + +export function printDirectiveDefinition(directive: AnyDirectiveDefinition): string { + const args = directive.arguments().size == 0 + ? "" + : [...directive.arguments().values()].map(arg => arg.toString()).join(', '); + // TODO: missing isRepeatable and locations + return `directive @${directive}${args}`; +} + +function printAppliedDirectives(element: AnySchemaElement): string { + const appliedDirectives = element.appliedDirectives(); + return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map((d: AnyDirective) => d.toString()).join(" "); +} + +function printScalarType(type: AnyScalarType): string { + return `scalar ${type.name}${printAppliedDirectives(type)}` +} + +function printObjectType(type: AnyObjectType): string { + // TODO: missing interfaces + return `type ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +} + +function printUnionType(type: AnyUnionType): string { + const possibleTypes = type.types.length ? ' = ' + type.types.join(' | ') : ''; + return `union ${type}${possibleTypes}`; +} + +function printInputObjectType(type: AnyInputObjectType): string { + return `input ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +} + +function printFields(fields: AnyFieldDefinition[] | AnyInputFieldDefinition[]): string { + return printBlock(fields.map((f: AnyFieldDefinition | AnyInputFieldDefinition) => indent + `${printField(f)}${printAppliedDirectives(f)}`)); +} + +function printField(field: AnyFieldDefinition | AnyInputFieldDefinition): string { + let args = ''; + if (field.kind == 'FieldDefinition' && field.arguments().size > 0) { + args = '(' + [...field.arguments().values()].map(arg => `${arg}${printAppliedDirectives(arg)}`).join(', ') + ')'; + } + return `${field.name}${args}: ${field.type()}`; +} + +function printBlock(items: string[]): string { + return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; +} diff --git a/core-js/src/utils.ts b/core-js/src/utils.ts new file mode 100644 index 000000000..be1bbf0ca --- /dev/null +++ b/core-js/src/utils.ts @@ -0,0 +1,14 @@ +/** + * For lack of a "home of federation utilities", this function is copy/pasted + * verbatim across the federation, gateway, and query-planner packages. Any changes + * made here should be reflected in the other two locations as well. + * + * @param condition + * @param message + * @throws + */ +export function assert(condition: any, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/core-js/tsconfig.test.json b/core-js/tsconfig.test.json new file mode 100644 index 000000000..792812e1e --- /dev/null +++ b/core-js/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.test.base", + "files": [], + "include": ["**/__tests__/**/*"], + "references": [ + { "path": "./" }, + ] +} diff --git a/tsconfig.build-stage-03.json b/tsconfig.build-stage-03.json index 4111728f1..2b8907c28 100644 --- a/tsconfig.build-stage-03.json +++ b/tsconfig.build-stage-03.json @@ -5,6 +5,7 @@ "files": [], "include": [], "references": [ + { "path": "./core-js" }, { "path": "./gateway-js" }, { "path": "./federation-integration-testsuite-js" }, ] From 4e4038035dfc6a2759ff76ae68737565119089c9 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Fri, 11 Jun 2021 16:09:12 +0200 Subject: [PATCH 03/22] Rename Document to Schema A few reasons (in rough order of importance): 1. Schema is more precise. A document references to any graphQL document which may comprise any kind of definition, including executable ones. Here we're truly represent a "schema", that is a coherent set of type system definitions. 2. It was not great that a SchemaElement was not really really an element of a Schema, but rather an element of a Document. It fixes that (in a imo better way than renaming SchemaElement to DocumentElement). 3. Schema maps a bit better to CQLSchema, so make it clearer what this essentially replaces. 4. Document is actually a builtin type name in javascript so that was problematic, which is why it was actually named `GraphQLDocument` but the `GraphQL` prefixing was inconcistent with the rest of the naming (and fwiw, I don't think prefixing everything with `GraphQL` offers good value; you know you are dealing with graphQL). This does mean the existing `Schema` type was renamed to `SchemaDefinition`. --- core-js/src/__tests__/definitions.test.ts | 42 +-- core-js/src/definitions.ts | 336 +++++++++++----------- core-js/src/print.ts | 28 +- 3 files changed, 203 insertions(+), 203 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index bf450bbb5..03d60583f 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -1,17 +1,17 @@ import { - AnyGraphQLDocument, + AnySchema, AnyObjectType, AnySchemaElement, AnyType, - GraphQLDocument, - MutableGraphQLDocument, + Schema, + MutableSchema, MutableObjectType, MutableType, ObjectType, Type } from '../../dist/definitions'; import { - printDocument + printSchema } from '../../dist/print'; function expectObjectType(type: Type | MutableType | undefined): asserts type is ObjectType | MutableObjectType { @@ -86,9 +86,9 @@ expect.extend({ } }); -test('building a simple mutable document programatically and converting to immutable', () => { - const mutDoc = MutableGraphQLDocument.empty(); - const mutQueryType = mutDoc.schema.setRoot('query', mutDoc.addObjectType('Query')); +test('building a simple mutable schema programatically and converting to immutable', () => { + const mutDoc = MutableSchema.empty(); + const mutQueryType = mutDoc.schemaDefinition.setRoot('query', mutDoc.addObjectType('Query')); const mutTypeA = mutDoc.addObjectType('A'); mutQueryType.addField('a', mutTypeA); mutTypeA.addField('q', mutQueryType); @@ -104,7 +104,7 @@ test('building a simple mutable document programatically and converting to immut const doc = mutDoc.toImmutable(); const queryType = doc.type('Query'); const typeA = doc.type('A'); - expect(queryType).toBe(doc.schema.root('query')); + expect(queryType).toBe(doc.schemaDefinition.root('query')); expectObjectType(queryType); expectObjectType(typeA); expect(queryType).toHaveField('a', typeA); @@ -113,7 +113,7 @@ test('building a simple mutable document programatically and converting to immut expect(typeA).toHaveDirective('key', new Map([['fields', 'a']])); }); -function parseAndValidateTestDocument(parser: (source: string) => Doc): Doc { +function parseAndValidateTestSchema(parser: (source: string) => S): S { const sdl = `schema { query: MyQuery @@ -134,21 +134,21 @@ type MyQuery { const typeA = doc.type('A')!; expectObjectType(queryType); expectObjectType(typeA); - expect(doc.schema.root('query')).toBe(queryType); + expect(doc.schemaDefinition.root('query')).toBe(queryType); expect(queryType).toHaveField('a', typeA); const f2 = typeA.field('f2'); expect(f2).toHaveDirective('inaccessible'); - expect(printDocument(doc)).toBe(sdl); + expect(printSchema(doc)).toBe(sdl); return doc; } -test('parse immutable document', () => { - parseAndValidateTestDocument(GraphQLDocument.parse); +test('parse immutable schema', () => { + parseAndValidateTestSchema(Schema.parse); }); -test('parse mutable document and modify', () => { - const doc = parseAndValidateTestDocument(MutableGraphQLDocument.parse); +test('parse mutable schema and modify', () => { + const doc = parseAndValidateTestSchema(MutableSchema.parse); const typeA = doc.type('A'); expectObjectType(typeA); expect(typeA).toHaveField('f1'); @@ -156,8 +156,8 @@ test('parse mutable document and modify', () => { expect(typeA).not.toHaveField('f1'); }); -test('removal of all directives of a document', () => { - const doc = MutableGraphQLDocument.parse(` +test('removal of all directives of a schema', () => { + const doc = MutableSchema.parse(` schema @foo { query: Query } @@ -182,7 +182,7 @@ test('removal of all directives of a document', () => { element.appliedDirectives().forEach(d => d.remove()); } - expect(printDocument(doc)).toBe( + expect(printSchema(doc)).toBe( `type A { a1: String a2: [Int] @@ -199,8 +199,8 @@ type Query { union U = A | B`); }); -test('removal of all inacessible elements of a document', () => { - const doc = MutableGraphQLDocument.parse(` +test('removal of all inacessible elements of a schema', () => { + const doc = MutableSchema.parse(` schema @foo { query: Query } @@ -227,7 +227,7 @@ test('removal of all inacessible elements of a document', () => { } } - expect(printDocument(doc)).toBe( + expect(printSchema(doc)).toBe( `type A { a2: [Int] } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index bd0920ccb..5f860c24e 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -28,8 +28,8 @@ export function defaultRootTypeName(root: SchemaRoot) { type ImmutableWorld = { detached: never, - document: GraphQLDocument, schema: Schema, + schemaDefinition: SchemaDefinition, schemaElement: SchemaElement, type: Type, namedType: NamedType, @@ -51,8 +51,8 @@ type ImmutableWorld = { type MutableWorld = { detached: undefined, - document: MutableGraphQLDocument, schema: MutableSchema, + schemaDefinition: MutableSchemaDefinition, schemaElement: MutableSchemaElement, type: MutableType, namedType: MutableNamedType, @@ -87,7 +87,7 @@ export type MutableInputType = MutableScalarType | MutableInputObjectType; export type MutableWrapperType = MutableListType; // Those exists to make it a bit easier to write code that work on both mutable and immutable variants, if one so wishes. -export type AnyGraphQLDocument = GraphQLDocument | MutableGraphQLDocument; +export type AnySchema = Schema | MutableSchema; export type AnySchemaElement = SchemaElement | MutableSchemaElement; export type AnyType = AnyOutputType | AnyInputType; export type AnyNamedType = AnyScalarType | AnyObjectType | AnyUnionType | AnyInputObjectType; @@ -100,7 +100,7 @@ export type AnyUnionType = UnionType | MutableUnionType; export type AnyInputObjectType = InputObjectType | MutableInputObjectType; export type AnyListType = ListType | MutableListType; -export type AnySchema = Schema | MutableSchema; +export type AnySchemaDefinition = SchemaDefinition | MutableSchemaDefinition; export type AnyDirectiveDefinition = DirectiveDefinition | MutableDirectiveDefinition; export type AnyDirective = Directive | MutableDirective; export type AnyFieldDefinition = FieldDefinition | MutableFieldDefinition; @@ -140,11 +140,11 @@ function valueEquals(a: any, b: any): boolean { return deepEqual(a, b); } -// TODO: make most of those a field since they are essentially a "property" of the element (document() excluded maybe). +// TODO: make most of those a field since they are essentially a "property" of the element (schema() excluded maybe). export interface SchemaElement { coordinate(): string; - document(): W['document'] | W['detached']; - parent(): W['schemaElement'] | W['document'] | W['detached']; + schema(): W['schema'] | W['detached']; + parent(): W['schemaElement'] | W['schema'] | W['detached']; source(): ASTNode | undefined; appliedDirectives(): readonly W['directive'][]; appliedDirective(name: string): W['directive'][]; @@ -154,7 +154,7 @@ export interface MutableSchemaElement extends SchemaElement { remove(): MutableSchemaElement[]; } -abstract class BaseElement

implements SchemaElement { +abstract class BaseElement

implements SchemaElement { protected readonly _appliedDirectives: W['directive'][] = []; constructor( @@ -164,13 +164,13 @@ abstract class BaseElement

extends BaseElement implements Named { +abstract class BaseNamedElement

extends BaseElement implements Named { constructor( readonly name: string, parent: P | W['detached'], @@ -217,15 +217,15 @@ abstract class BaseNamedElement

extends BaseNamedElement { +abstract class BaseNamedType extends BaseNamedElement { protected readonly _referencers: Set = new Set(); protected constructor( name: string, - document: W['document'] | W['detached'], + schema: W['schema'] | W['detached'], source?: ASTNode ) { - super(name, document, source); + super(name, schema, source); } coordinate(): string { @@ -246,39 +246,39 @@ abstract class BaseNamedType extends BaseNamedElement { - private _schema: W['schema'] | undefined = undefined; +class BaseSchema { + private _schemaDefinition: W['schemaDefinition'] | undefined = undefined; protected readonly builtInTypes: Map = new Map(); protected readonly typesMap: Map = new Map(); protected readonly directivesMap: Map = new Map(); protected constructor() {} - kind: 'Document' = 'Document'; + kind: 'Schema' = 'Schema'; // Used only through cheating the type system. - private setSchema(schema: W['schema']) { - this._schema = schema; + private setSchemaDefinition(schemaDefinition: W['schemaDefinition']) { + this._schemaDefinition = schemaDefinition; } private setBuiltIn(name: string, type: W['scalarType']) { this.builtInTypes.set(name, type); } - get schema(): W['schema'] { - assert(this._schema, "Badly constructor document; doesn't have a schema"); - return this._schema; + get schemaDefinition(): W['schemaDefinition'] { + assert(this._schemaDefinition, "Badly constructed schema; doesn't have a schema definition"); + return this._schemaDefinition; } /** - * A map of all the types defined on this document _excluding_ the built-in types. + * A map of all the types defined on this schema _excluding_ the built-in types. */ get types(): ReadonlyMap { return this.typesMap; } /** - * The type of the provide name in this document if one is defined or if it is the name of a built-in. + * 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): W['namedType'] | undefined { const type = this.typesMap.get(name); @@ -314,8 +314,8 @@ class BaseGraphQLDocument { } *allSchemaElement(): Generator { - if (this._schema) { - yield this._schema; + if (this._schemaDefinition) { + yield this._schemaDefinition; } for (const type of this.types.values()) { yield type; @@ -328,37 +328,37 @@ class BaseGraphQLDocument { } } -export class GraphQLDocument extends BaseGraphQLDocument { - // Note that because typescript typesystem is structural, we need GraphQLDocument to some incompatible - // properties in GraphQLDocument that are not in MutableGraphQLDocument (not having MutableGraphQLDocument be a subclass - // of GraphQLDocument is not sufficient). This is the why of the 'mutable' field (the `toMutable` property - // also achieve this in practice, but we could want to add a toMutable() to MutableGraphQLDocument (that +export class Schema extends BaseSchema { + // Note that because typescript typesystem is structural, we need Schema to some incompatible + // properties in Schema that are not in MutableSchema (not having MutableSchema be a subclass + // of Schema is not sufficient). This is the why of the 'mutable' field (the `toMutable` property + // also achieve this in practice, but we could want to add a toMutable() to MutableSchema (that // just return `this`) for some reason, so the field is a bit clearer/safer). mutable: false = false; - static parse(source: string | Source): GraphQLDocument { - return buildDocumentInternal(parse(source), Ctors.immutable); + static parse(source: string | Source): Schema { + return buildSchemaInternal(parse(source), Ctors.immutable); } - toMutable(): MutableGraphQLDocument { + toMutable(): MutableSchema { return copy(this, Ctors.mutable); } } -export class MutableGraphQLDocument extends BaseGraphQLDocument { +export class MutableSchema extends BaseSchema { mutable: true = true; - static empty(): MutableGraphQLDocument { - return Ctors.mutable.addSchema(Ctors.mutable.document()); + static empty(): MutableSchema { + return Ctors.mutable.addSchemaDefinition(Ctors.mutable.schema()); } - static parse(source: string | Source): MutableGraphQLDocument { - return buildDocumentInternal(parse(source), Ctors.mutable); + static parse(source: string | Source): MutableSchema { + return buildSchemaInternal(parse(source), Ctors.mutable); } private ensureTypeNotFound(name: string) { if (this.type(name)) { - throw new GraphQLError(`Type ${name} already exists in this document`); + throw new GraphQLError(`Type ${name} already exists in this schema`); } } @@ -405,16 +405,16 @@ export class MutableGraphQLDocument extends BaseGraphQLDocument { this.directivesMap.set(directive.name, directive); } - toImmutable(): GraphQLDocument { + toImmutable(): Schema { return copy(this, Ctors.immutable); } } -export class Schema extends BaseElement { +export class SchemaDefinition extends BaseElement { protected readonly rootsMap: Map = new Map(); protected constructor( - parent: W['document'] | W['detached'], + parent: W['schema'] | W['detached'], source?: ASTNode ) { super(parent, source); @@ -424,7 +424,7 @@ export class Schema extends BaseElement { return this.rootsMap; @@ -447,11 +447,11 @@ export class Schema extends BaseElement implements MutableSchemaElement { +export class MutableSchemaDefinition extends SchemaDefinition implements MutableSchemaElement { setRoot(rootType: SchemaRoot, objectType: MutableObjectType): MutableObjectType { - if (objectType.document() != this.document()) { - const attachement = objectType.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot use provided type ${objectType} for ${rootType} as it is not attached to this document (it is ${attachement})`); + if (objectType.schema() != this.schema()) { + const attachement = objectType.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot use provided type ${objectType} for ${rootType} as it is not attached to this schema (it is ${attachement})`); } this.rootsMap.set(rootType, objectType); return objectType; @@ -483,16 +483,16 @@ export class MutableScalarType extends ScalarType implements Mutab } /** - * Removes this type definition from its parent document. + * Removes this type definition from its parent schema. * - * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * After calling this method, this type will be "detached": it wil have no parent, schema, fields, * values, directives, etc... * - * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ remove(): MutableSchemaElement[] { @@ -518,10 +518,10 @@ export class ObjectType extends BaseNamedType< protected constructor( name: string, - document: W['document'] | undefined, + schema: W['schema'] | undefined, source?: ASTNode ) { - super(name, document, source); + super(name, schema, source); } kind: 'ObjectType' = 'ObjectType'; @@ -551,9 +551,9 @@ export class MutableObjectType extends ObjectType implements Mutab if (this.field(name)) { throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); } - if (type.document() != this.document()) { - const attachement = type.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this document (it is ${attachement})`); + if (type.schema() != this.schema()) { + const attachement = type.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this schema (it is ${attachement})`); } const field = Ctors.mutable.createFieldDefinition(name, this, type); this.fieldsMap.set(name, field); @@ -565,16 +565,16 @@ export class MutableObjectType extends ObjectType implements Mutab } /** - * Removes this type definition from its parent document. + * Removes this type definition from its parent schema. * - * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * After calling this method, this type will be "detached": it wil have no parent, schema, fields, * values, directives, etc... * - * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ remove(): MutableSchemaElement[] { @@ -603,10 +603,10 @@ export class UnionType extends BaseNamedType extends BaseNamedType implements MutableSchemaElement { addType(type: MutableObjectType): void { - if (type.document() != this.document()) { - const attachement = type.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot add provided type ${type} to union ${this} as it is not attached to this document (it is ${attachement})`); + if (type.schema() != this.schema()) { + const attachement = type.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot add provided type ${type} to union ${this} as it is not attached to this schema (it is ${attachement})`); } if (!this.typesList.includes(type)) { this.typesList.push(type); @@ -639,16 +639,16 @@ export class MutableUnionType extends UnionType implements Mutable } /** - * Removes this type definition from its parent document. + * Removes this type definition from its parent schema. * - * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * After calling this method, this type will be "detached": it wil have no parent, schema, fields, * values, directives, etc... * - * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ remove(): MutableSchemaElement[] { @@ -675,10 +675,10 @@ export class InputObjectType extends BaseNamed protected constructor( name: string, - document: W['document'] | undefined, + schema: W['schema'] | undefined, source?: ASTNode ) { - super(name, document, source); + super(name, schema, source); } kind: 'InputObjectType' = 'InputObjectType'; @@ -705,9 +705,9 @@ export class MutableInputObjectType extends InputObjectType implem if (this.field(name)) { throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); } - if (type.document() != this.document()) { - const attachement = type.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this document (it is ${attachement})`); + if (type.schema() != this.schema()) { + const attachement = type.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this schema (it is ${attachement})`); } const field = Ctors.mutable.createInputFieldDefinition(name, this, type); this.fieldsMap.set(name, field); @@ -719,16 +719,16 @@ export class MutableInputObjectType extends InputObjectType implem } /** - * Removes this type definition from its parent document. + * Removes this type definition from its parent schema. * - * After calling this method, this type will be "detached": it wil have no parent, document, fields, + * After calling this method, this type will be "detached": it wil have no parent, schema, fields, * values, directives, etc... * - * Note that it is always allowed to remove a type, but this may make a valid document + * 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 document of this type (before the removal) that were + * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ remove(): MutableSchemaElement[] { @@ -757,8 +757,8 @@ export class ListType { kind: 'ListType' = 'ListType'; - document(): W['document'] { - return this.baseType().document() as W['document']; + schema(): W['schema'] { + return this.baseType().schema() as W['schema']; } ofType(): T { @@ -823,13 +823,13 @@ export class FieldDefinition extends BaseNamed export class MutableFieldDefinition extends FieldDefinition implements MutableSchemaElement { setType(type: MutableOutputType): MutableFieldDefinition { - if (!this.document()) { + if (!this.schema()) { // Let's not allow manipulating detached elements too much as this could make our lives harder. throw new GraphQLError(`Cannot set the type of field ${this.name} as it is detached`); } - if (type.document() != this.document()) { - const attachement = type.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot set provided type ${type} to field ${this.name} as it is not attached to this document (it is ${attachement})`); + if (type.schema() != this.schema()) { + const attachement = type.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot set provided type ${type} to field ${this.name} as it is not attached to this schema (it is ${attachement})`); } this._type = type; return this; @@ -842,7 +842,7 @@ export class MutableFieldDefinition extends FieldDefinition implem /** * Removes this field definition from its parent type. * - * After calling this method, this field definition will be "detached": it wil have no parent, document, type, + * After calling this method, this field definition will be "detached": it wil have no parent, schema, type, * arguments or directives. */ remove(): MutableSchemaElement[] { @@ -896,13 +896,13 @@ export class InputFieldDefinition extends Base export class MutableInputFieldDefinition extends InputFieldDefinition implements MutableSchemaElement { setType(type: MutableInputType): MutableInputFieldDefinition { - if (!this.document()) { + if (!this.schema()) { // Let's not allow manipulating detached elements too much as this could make our lives harder. throw new GraphQLError(`Cannot set the type of input field ${this.name} as it is detached`); } - if (type.document() != this.document()) { - const attachement = type.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot set provided type ${type} to input field ${this.name} as it is not attached to this document (it is ${attachement})`); + if (type.schema() != this.schema()) { + const attachement = type.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot set provided type ${type} to input field ${this.name} as it is not attached to this schema (it is ${attachement})`); } this._type = type; return this; @@ -915,7 +915,7 @@ export class MutableInputFieldDefinition extends InputFieldDefinition extends BaseNamedElement { +export class DirectiveDefinition extends BaseNamedElement { protected readonly _args: Map = new Map(); protected constructor( name: string, - document: W['document'] | W['detached'], + schema: W['schema'] | W['detached'], source?: ASTNode ) { - super(name, document, source); + super(name, schema, source); } kind: 'Directive' = 'Directive'; @@ -1031,13 +1031,13 @@ export class DirectiveDefinition extends BaseN export class MutableDirectiveDefinition extends DirectiveDefinition implements MutableSchemaElement { addArgument(name: string, type: MutableInputType, defaultValue?: any): MutableArgumentDefinition { - if (!this.document()) { + if (!this.schema()) { // Let's not allow manipulating detached elements too much as this could make our lives harder. throw new GraphQLError(`Cannot add argument to directive definition ${this.name} as it is detached`); } - if (type.document() != this.document()) { - const attachement = type.document() ? 'attached to another document' : 'detached'; - throw new GraphQLError(`Cannot use type ${type} for argument of directive definition ${this.name} as it is not attached to this document (it is ${attachement})`); + if (type.schema() != this.schema()) { + const attachement = type.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot use type ${type} for argument of directive definition ${this.name} as it is not attached to this schema (it is ${attachement})`); } const newArg = Ctors.mutable.createDirectiveArgumentDefinition(name, this, type, defaultValue); this._args.set(name, newArg); @@ -1058,8 +1058,8 @@ export class Directive implements Named { ) { } - document(): W['document'] | W['detached'] { - return this._parent?.document(); + schema(): W['schema'] | W['detached'] { + return this._parent?.schema(); } parent(): W['schemaElement'] | W['detached'] { @@ -1067,7 +1067,7 @@ export class Directive implements Named { } definition(): W['directiveDefinition'] | W['detached'] { - const doc = this.document(); + const doc = this.schema(); return doc?.directive(this.name); } @@ -1125,8 +1125,8 @@ class Ctors { // are protected, but we still need to access them here, hence the `Function.prototype` hack. // Note: this is fairly well contained so manageable but certainly ugly and a bit error-prone, so if someone knowns a better way? static immutable = new Ctors( - () => new (Function.prototype.bind.call(GraphQLDocument, null)), - (parent, source) => new (Function.prototype.bind.call(Schema, null, parent, source)), + () => new (Function.prototype.bind.call(Schema, null)), + (parent, source) => new (Function.prototype.bind.call(SchemaDefinition, null, parent, source)), (name, doc, source) => new (Function.prototype.bind.call(ScalarType, null, name, doc, source)), (name, doc, source) => new (Function.prototype.bind.call(ObjectType, null, name, doc, source)), (name, doc, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, source)), @@ -1147,8 +1147,8 @@ class Ctors { ); static mutable = new Ctors( - () => new (Function.prototype.bind.call(MutableGraphQLDocument, null)), - (parent, source) => new (Function.prototype.bind.call(MutableSchema, null, parent, source)), + () => new (Function.prototype.bind.call(MutableSchema, null)), + (parent, source) => new (Function.prototype.bind.call(MutableSchemaDefinition, null, parent, source)), (name, doc, source) => new (Function.prototype.bind.call(MutableScalarType, null, name, doc, source)), (name, doc, source) => new (Function.prototype.bind.call(MutableObjectType, null, name, doc, source)), (name, doc, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, source)), @@ -1164,69 +1164,69 @@ class Ctors { ); constructor( - private readonly createDocument: () => W['document'], - private readonly createSchema: (parent: W['document'] | W['detached'], source?: ASTNode) => W['schema'], - readonly createScalarType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['scalarType'], - readonly createObjectType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['objectType'], - readonly createUnionType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['unionType'], - readonly createInputObjectType: (name: string, document: W['document'] | W['detached'], source?: ASTNode) => W['inputObjectType'], + private readonly createSchema: () => W['schema'], + private readonly createSchemaDefinition: (parent: W['schema'] | W['detached'], source?: ASTNode) => W['schemaDefinition'], + readonly createScalarType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['scalarType'], + readonly createObjectType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['objectType'], + readonly createUnionType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['unionType'], + readonly createInputObjectType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['inputObjectType'], readonly createList: (type: T) => W['listType'], readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], readonly createInputFieldDefinition: (name: string, parent: W['inputObjectType'] | W['detached'], type: W['inputType'], source?: ASTNode) => W['inputFieldDefinition'], readonly createFieldArgumentDefinition: (name: string, parent: W['fieldDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['fieldArgumentDefinition'], readonly createDirectiveArgumentDefinition: (name: string, parent: W['directiveDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['directiveArgumentDefinition'], - readonly createDirectiveDefinition: (name: string, parent: W['document'] | W['detached'], source?: ASTNode) => W['directiveDefinition'], + readonly createDirectiveDefinition: (name: string, parent: W['schema'] | W['detached'], source?: ASTNode) => W['directiveDefinition'], readonly createDirective: (name: string, parent: W['schemaElement'] | W['detached'], args: Map, source?: ASTNode) => W['directive'], readonly checkDetached: (v: T | undefined) => T | W['detached'] ) { } - document(): W['document'] { - const doc = this.createDocument(); + schema(): W['schema'] { + const doc = this.createSchema(); for (const builtIn of builtInTypes) { - BaseGraphQLDocument.prototype['setBuiltIn'].call(doc, builtIn, this.createScalarType(builtIn, doc)); + BaseSchema.prototype['setBuiltIn'].call(doc, builtIn, this.createScalarType(builtIn, doc)); } return doc; } - addSchema(document: W['document'], source?: ASTNode): W['document'] { - const schema = this.createSchema(document, source); - BaseGraphQLDocument.prototype['setSchema'].call(document, schema); - return document; + addSchemaDefinition(schema: W['schema'], source?: ASTNode): W['schema'] { + const schemaDefinition = this.createSchemaDefinition(schema, source); + BaseSchema.prototype['setSchemaDefinition'].call(schema, schemaDefinition); + return schema; } - createNamedType(kind: string, name: string, document: W['document'], source?: ASTNode): W['namedType'] { + createNamedType(kind: string, name: string, schema: W['schema'], source?: ASTNode): W['namedType'] { switch (kind) { case 'ScalarType': - return this.createScalarType(name, document, source); + return this.createScalarType(name, schema, source); case 'ObjectType': - return this.createObjectType(name, document, source); + return this.createObjectType(name, schema, source); case 'UnionType': - return this.createUnionType(name, document, source); + return this.createUnionType(name, schema, source); case 'InputObjectType': - return this.createInputObjectType(name, document, source); + return this.createInputObjectType(name, schema, source); default: assert(false, "Missing branch for type " + kind); } } } -function addTypeDefinition(namedType: W['namedType'], document: W['document']) { - (document.types as Map).set(namedType.name, namedType); +function addTypeDefinition(namedType: W['namedType'], schema: W['schema']) { + (schema.types as Map).set(namedType.name, namedType); } -function removeTypeDefinition(namedType: W['namedType'], document: W['document']) { - (document.types as Map).delete(namedType.name); +function removeTypeDefinition(namedType: W['namedType'], schema: W['schema']) { + (schema.types as Map).delete(namedType.name); } -function addDirectiveDefinition(definition: W['directiveDefinition'], document: W['document']) { - (document.directives as Map).set(definition.name, definition); +function addDirectiveDefinition(definition: W['directiveDefinition'], schema: W['schema']) { + (schema.directives as Map).set(definition.name, definition); } -function addRoot(root: SchemaRoot, typeName: string, schema: W['schema']) { - const type = schema.document()!.type(typeName)! as W['objectType']; - (schema.roots as Map).set(root, type); - addReferencerToType(schema, type); +function addRoot(root: SchemaRoot, typeName: string, schemaDefinition: W['schemaDefinition']) { + const type = schemaDefinition.schema()!.type(typeName)! as W['objectType']; + (schemaDefinition.roots as Map).set(root, type); + addReferencerToType(schemaDefinition, type); } function addFieldArg(arg: W['fieldArgumentDefinition'], field: W['fieldDefinition']) { @@ -1242,7 +1242,7 @@ function addField(field: W['fieldDefinition'] | W['inputFieldDe } function addTypeToUnion(typeName: string, unionType: W['unionType']) { - const type = unionType.document()!.type(typeName)! as W['objectType']; + const type = unionType.schema()!.type(typeName)! as W['objectType']; (unionType.types as W['objectType'][]).push(type); addReferencerToType(unionType, type); } @@ -1270,16 +1270,16 @@ function buildValue(value?: ValueNode): any { return value ? valueFromASTUntyped(value) : undefined; } -function buildDocumentInternal(documentNode: DocumentNode, ctors: Ctors): W['document'] { - const doc = ctors.document(); +function buildSchemaInternal(documentNode: DocumentNode, ctors: Ctors): W['schema'] { + const doc = ctors.schema(); buildNamedTypeShallow(documentNode, doc, ctors); for (const definitionNode of documentNode.definitions) { switch (definitionNode.kind) { case 'OperationDefinition': case 'FragmentDefinition': - throw new GraphQLError("Invalid executable definition found while building document", definitionNode); + throw new GraphQLError("Invalid executable definition found while building schema", definitionNode); case 'SchemaDefinition': - buildSchema(definitionNode, doc, ctors); + buildSchemaDefinition(definitionNode, doc, ctors); break; case 'ScalarTypeDefinition': case 'ObjectTypeDefinition': @@ -1305,7 +1305,7 @@ function buildDocumentInternal(documentNode: DocumentNode, ctor return doc; } -function buildNamedTypeShallow(documentNode: DocumentNode, document: W['document'], ctors: Ctors) { +function buildNamedTypeShallow(documentNode: DocumentNode, schema: W['schema'], ctors: Ctors) { for (const definitionNode of documentNode.definitions) { switch (definitionNode.kind) { case 'ScalarTypeDefinition': @@ -1314,7 +1314,7 @@ function buildNamedTypeShallow(documentNode: DocumentNode, docu case 'UnionTypeDefinition': case 'EnumTypeDefinition': case 'InputObjectTypeDefinition': - addTypeDefinition(ctors.createNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value, document, definitionNode), document); + addTypeDefinition(ctors.createNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value, schema, definitionNode), schema); break; case 'SchemaExtension': case 'ScalarTypeExtension': @@ -1334,11 +1334,11 @@ function withoutTrailingDefinition(str: string): string { return str.slice(0, str.length - 'Definition'.length); } -function buildSchema(schemaNode: SchemaDefinitionNode, document: W['document'], ctors: Ctors) { - ctors.addSchema(document, schemaNode); - buildAppliedDirectives(schemaNode, document.schema, ctors); +function buildSchemaDefinition(schemaNode: SchemaDefinitionNode, schema: W['schema'], ctors: Ctors) { + ctors.addSchemaDefinition(schema, schemaNode); + buildAppliedDirectives(schemaNode, schema.schemaDefinition, ctors); for (const opTypeNode of schemaNode.operationTypes) { - addRoot(opTypeNode.operation, opTypeNode.type.name.value, document.schema); + addRoot(opTypeNode.operation, opTypeNode.type.name.value, schema.schemaDefinition); } } @@ -1385,7 +1385,7 @@ function buildNamedTypeInner(definitionNode: DefinitionNode & N } function buildFieldDefinition(fieldNode: FieldDefinitionNode, parentType: W['objectType'], ctors: Ctors): W['fieldDefinition'] { - const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.document()!, ctors) as W['outputType']; + const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.schema()!, ctors) as W['outputType']; const builtField = ctors.createFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); buildAppliedDirectives(fieldNode, builtField, ctors); for (const inputValueDef of fieldNode.arguments ?? []) { @@ -1395,19 +1395,19 @@ function buildFieldDefinition(fieldNode: FieldDefinitionNode, p return builtField; } -function buildWrapperTypeOrTypeRef(typeNode: TypeNode, document: W['document'], ctors: Ctors): W['type'] { +function buildWrapperTypeOrTypeRef(typeNode: TypeNode, schema: W['schema'], ctors: Ctors): W['type'] { switch (typeNode.kind) { case 'ListType': - return ctors.createList(buildWrapperTypeOrTypeRef(typeNode.type, document, ctors)); + return ctors.createList(buildWrapperTypeOrTypeRef(typeNode.type, schema, ctors)); case 'NonNullType': throw new Error('TODO'); default: - return document.type(typeNode.name.value)!; + return schema.type(typeNode.name.value)!; } } function buildFieldArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['fieldDefinition'], ctors: Ctors): W['fieldArgumentDefinition'] { - const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.document()!, ctors) as W['inputType']; + const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.schema()!, ctors) as W['inputType']; const built = ctors.createFieldArgumentDefinition(inputNode.name.value, parent, type, buildValue(inputNode.defaultValue), inputNode); buildAppliedDirectives(inputNode, built, ctors); addReferencerToType(built, type); @@ -1415,14 +1415,14 @@ function buildFieldArgumentDefinition(inputNode: InputValueDefi } function buildInputFieldDefinition(fieldNode: InputValueDefinitionNode, parentType: W['inputObjectType'], ctors: Ctors): W['inputFieldDefinition'] { - const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.document()!, ctors) as W['inputType']; + const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.schema()!, ctors) as W['inputType']; const builtField = ctors.createInputFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); buildAppliedDirectives(fieldNode, builtField, ctors); addReferencerToType(builtField, type); return builtField; } -function buildDirectiveDefinition(directiveNode: DirectiveDefinitionNode, parent: W['document'], ctors: Ctors): W['directiveDefinition'] { +function buildDirectiveDefinition(directiveNode: DirectiveDefinitionNode, parent: W['schema'], ctors: Ctors): W['directiveDefinition'] { const builtDirective = ctors.createDirectiveDefinition(directiveNode.name.value, parent, directiveNode); for (const inputValueDef of directiveNode.arguments ?? []) { addDirectiveArg(buildDirectiveArgumentDefinition(inputValueDef, builtDirective, ctors), builtDirective); @@ -1431,19 +1431,19 @@ function buildDirectiveDefinition(directiveNode: DirectiveDefin } function buildDirectiveArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['directiveDefinition'], ctors: Ctors): W['directiveArgumentDefinition'] { - const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.document()!, ctors) as W['inputType']; + const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.schema()!, ctors) as W['inputType']; const built = ctors.createDirectiveArgumentDefinition(inputNode.name.value, parent, type, buildValue(inputNode.defaultValue), inputNode); buildAppliedDirectives(inputNode, built, ctors); addReferencerToType(built, type); return built; } -function copy(source: WS['document'], destCtors: Ctors): WD['document'] { - const doc = destCtors.addSchema(destCtors.document(), source.schema.source()); +function copy(source: WS['schema'], destCtors: Ctors): WD['schema'] { + const doc = destCtors.addSchemaDefinition(destCtors.schema(), source.schemaDefinition.source()); for (const type of source.types.values()) { addTypeDefinition(copyNamedTypeShallow(type, doc, destCtors), doc); } - copySchemaInner(source.schema, doc.schema, destCtors); + copySchemaDefinitionInner(source.schemaDefinition, doc.schemaDefinition, destCtors); for (const type of source.types.values()) { copyNamedTypeInner(type, doc.type(type.name)!, destCtors); } @@ -1453,7 +1453,7 @@ function copy(source: WS['document'], destCt return doc; } -function copySchemaInner(source: WS['schema'], dest: WD['schema'], destCtors: Ctors) { +function copySchemaDefinitionInner(source: WS['schemaDefinition'], dest: WD['schemaDefinition'], destCtors: Ctors) { for (const [root, type] of source.roots.entries()) { addRoot(root, type.name, dest); } @@ -1476,8 +1476,8 @@ function copyDirective(source: WS['directive // Because types can refer to one another (through fields or directive applications), we first create a shallow copy of // all types, and then copy fields (see below) assuming that the type "shell" exists. -function copyNamedTypeShallow(source: WS['namedType'], document: WD['document'], destCtors: Ctors): WD['namedType'] { - return destCtors.createNamedType(source.kind, source.name, document, source.source()); +function copyNamedTypeShallow(source: WS['namedType'], schema: WD['schema'], destCtors: Ctors): WD['namedType'] { + return destCtors.createNamedType(source.kind, source.name, schema, source.source()); } function copyNamedTypeInner(source: WS['namedType'], dest: WD['namedType'], destCtors: Ctors) { @@ -1507,7 +1507,7 @@ function copyNamedTypeInner(source: WS['name } function copyFieldDefinition(source: WS['fieldDefinition'], destParent: WD['objectType'], destCtors: Ctors): WD['fieldDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as WD['outputType']; + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as WD['outputType']; const copiedField = destCtors.createFieldDefinition(source.name, destParent, type, source.source()); copyAppliedDirectives(source, copiedField, destCtors); for (const sourceArg of source.arguments().values()) { @@ -1518,14 +1518,14 @@ function copyFieldDefinition(source: WS['fie } function copyInputFieldDefinition(source: WS['inputFieldDefinition'], destParent: WD['inputObjectType'], destCtors: Ctors): WD['inputFieldDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as WD['inputType']; + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as WD['inputType']; const copied = destCtors.createInputFieldDefinition(source.name, destParent, type, source.source()); copyAppliedDirectives(source, copied, destCtors); addReferencerToType(copied, type); return copied; } -function copyWrapperTypeOrTypeRef(source: WS['type'] | WS['detached'], destParent: WD['document'], destCtors: Ctors): WD['type'] | WD['detached'] { +function copyWrapperTypeOrTypeRef(source: WS['type'] | WS['detached'], destParent: WD['schema'], destCtors: Ctors): WD['type'] | WD['detached'] { if (source == undefined) { return destCtors.checkDetached(undefined); } @@ -1538,14 +1538,14 @@ function copyWrapperTypeOrTypeRef(source: WS } function copyFieldArgumentDefinition(source: WS['fieldArgumentDefinition'], destParent: WD['fieldDefinition'], destCtors: Ctors): WD['fieldArgumentDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as WD['inputType']; + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as WD['inputType']; const copied = destCtors.createFieldArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source()); copyAppliedDirectives(source, copied, destCtors); addReferencerToType(copied, type); return copied; } -function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['document'], destCtors: Ctors): WD['directiveDefinition'] { +function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['schema'], destCtors: Ctors): WD['directiveDefinition'] { const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, source.source()); for (const sourceArg of source.arguments().values()) { addDirectiveArg(copyDirectiveArgumentDefinition(sourceArg, copiedDirective, destCtors), copiedDirective); @@ -1553,7 +1553,7 @@ function copyDirectiveDefinition(source: WS[ return copiedDirective; } function copyDirectiveArgumentDefinition(source: WS['directiveArgumentDefinition'], destParent: WD['directiveDefinition'], destCtors: Ctors): WD['directiveArgumentDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.document()!, destCtors) as InputType; + const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as InputType; const copied = destCtors.createDirectiveArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source()); copyAppliedDirectives(source, copied, destCtors); addReferencerToType(copied, type); diff --git a/core-js/src/print.ts b/core-js/src/print.ts index ed08a1ce2..b474f6887 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -1,14 +1,14 @@ import { - AnyDirective, + AnyDirective, AnyDirectiveDefinition, AnyFieldDefinition, - AnyGraphQLDocument, + AnySchema, AnyInputFieldDefinition, AnyInputObjectType, AnyNamedType, AnyObjectType, AnyScalarType, - AnySchema, + AnySchemaDefinition, AnySchemaElement, AnyUnionType, defaultRootTypeName, @@ -29,21 +29,21 @@ const defaultDirectiveFilter = (directive: AnyDirectiveDefinition) => ( !federationDirectivesNames.includes(directive.name) ); -export function printDocument(document: AnyGraphQLDocument): string { - return printFilteredDocument(document, defaultTypeFilter, defaultDirectiveFilter); +export function printSchema(schema: AnySchema): string { + return printFilteredSchema(schema, defaultTypeFilter, defaultDirectiveFilter); } -function printFilteredDocument( - document: AnyGraphQLDocument, +function printFilteredSchema( + schema: AnySchema, typeFilter: (type: AnyNamedType) => boolean, directiveFilter: (type: AnyDirectiveDefinition) => boolean ): string { - const directives = [...document.directives.values()].filter(directiveFilter); - const types = [...document.types.values()] + const directives = [...schema.directives.values()].filter(directiveFilter); + const types = [...schema.types.values()] .sort((type1, type2) => type1.name.localeCompare(type2.name)) .filter(typeFilter); return ( - [printSchema(document.schema)] + [printSchemaDefinition(schema.schemaDefinition)] .concat( directives.map(directive => printDirectiveDefinition(directive)), types.map(type => printTypeDefinition(type)), @@ -53,11 +53,11 @@ function printFilteredDocument( ); } -function printSchema(schema: AnySchema): string | undefined { - if (isSchemaOfCommonNames(schema)) { +function printSchemaDefinition(schemaDefinition: AnySchemaDefinition): string | undefined { + if (isSchemaOfCommonNames(schemaDefinition)) { return; } - const rootEntries = [...schema.roots.entries()].map(([root, type]) => `${indent}${root}: ${type}`); + const rootEntries = [...schemaDefinition.roots.entries()].map(([root, type]) => `${indent}${root}: ${type}`); return `schema {\n${rootEntries.join('\n')}\n}`; } @@ -73,7 +73,7 @@ function printSchema(schema: AnySchema): string | undefined { * * When using this naming convention, the schema description can be omitted. */ -function isSchemaOfCommonNames(schema: AnySchema): boolean { +function isSchemaOfCommonNames(schema: AnySchemaDefinition): boolean { for (const [root, type] of schema.roots) { if (type.name != defaultRootTypeName(root)) { return false; From 67b4ff11fde64971bdd9bc2a5caea0f70c784844 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Mon, 14 Jun 2021 14:41:54 +0200 Subject: [PATCH 04/22] Better handling of built-ins and complete directive definitions --- core-js/src/__tests__/definitions.test.ts | 41 +- core-js/src/definitions.ts | 503 +++++++++++++++++----- core-js/src/federation.ts | 24 +- core-js/src/print.ts | 38 +- 4 files changed, 450 insertions(+), 156 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 03d60583f..15917bd11 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -8,11 +8,15 @@ import { MutableObjectType, MutableType, ObjectType, - Type + Type, + BuiltIns } from '../../dist/definitions'; import { printSchema } from '../../dist/print'; +import { + federationBuiltIns +} from '../../dist/federation'; function expectObjectType(type: Type | MutableType | undefined): asserts type is ObjectType | MutableObjectType { expect(type).toBeDefined(); @@ -87,7 +91,7 @@ expect.extend({ }); test('building a simple mutable schema programatically and converting to immutable', () => { - const mutDoc = MutableSchema.empty(); + const mutDoc = MutableSchema.empty(federationBuiltIns); const mutQueryType = mutDoc.schemaDefinition.setRoot('query', mutDoc.addObjectType('Query')); const mutTypeA = mutDoc.addObjectType('A'); mutQueryType.addField('a', mutTypeA); @@ -113,7 +117,7 @@ test('building a simple mutable schema programatically and converting to immutab expect(typeA).toHaveDirective('key', new Map([['fields', 'a']])); }); -function parseAndValidateTestSchema(parser: (source: string) => S): S { +function parseAndValidateTestSchema(parser: (source: string, builtIns: BuiltIns) => S): S { const sdl = `schema { query: MyQuery @@ -128,7 +132,7 @@ type MyQuery { a: A b: Int }`; - const doc = parser(sdl); + const doc = parser(sdl, federationBuiltIns); const queryType = doc.type('MyQuery')!; const typeA = doc.type('A')!; @@ -176,14 +180,24 @@ test('removal of all directives of a schema', () => { } union U @foobar = A | B - `); + + directive @foo on SCHEMA | FIELD_DEFINITION + directive @foobar on UNION + directive @bar on ARGUMENT_DEFINITION + `, federationBuiltIns); for (const element of doc.allSchemaElement()) { element.appliedDirectives().forEach(d => d.remove()); } expect(printSchema(doc)).toBe( -`type A { +`directive @foo on SCHEMA | FIELD_DEFINITION + +directive @foobar on UNION + +directive @bar on ARGUMENT_DEFINITION + +type A { a1: String a2: [Int] } @@ -219,7 +233,10 @@ test('removal of all inacessible elements of a schema', () => { } union U @inaccessible = A | B - `); + + directive @foo on SCHEMA | FIELD_DEFINITION + directive @bar on ARGUMENT_DEFINITION + `, federationBuiltIns); for (const element of doc.allSchemaElement()) { if (element.appliedDirective('inaccessible').length > 0) { @@ -228,7 +245,15 @@ test('removal of all inacessible elements of a schema', () => { } expect(printSchema(doc)).toBe( -`type A { +`schema @foo { + query: Query +} + +directive @foo on SCHEMA | FIELD_DEFINITION + +directive @bar on ARGUMENT_DEFINITION + +type A { a2: [Int] } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 5f860c24e..af89106c3 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -2,6 +2,7 @@ import { ASTNode, DefinitionNode, DirectiveDefinitionNode, + DirectiveLocationEnum, DirectiveNode, DocumentNode, FieldDefinitionNode, @@ -16,6 +17,7 @@ import { } from "graphql"; import { assert } from "./utils"; import deepEqual from 'deep-equal'; +import { DirectiveLocation } from "graphql"; export type QueryRoot = 'query'; export type MutationRoot = 'mutation'; @@ -46,14 +48,17 @@ type ImmutableWorld = { inputFieldDefinition: InputFieldDefinition, directiveDefinition: DirectiveDefinition, directiveArgumentDefinition: ArgumentDefinition, - directive: Directive + directive: Directive, + outputTypeReferencer: OutputTypeReferencer, + inputTypeReferencer: InputTypeReferencer, + objectTypeReferencer: ObjectTypeReferencer, } type MutableWorld = { detached: undefined, schema: MutableSchema, schemaDefinition: MutableSchemaDefinition, - schemaElement: MutableSchemaElement, + schemaElement: MutableSchemaElement, type: MutableType, namedType: MutableNamedType, objectType: MutableObjectType, @@ -69,7 +74,10 @@ type MutableWorld = { inputFieldDefinition: MutableInputFieldDefinition, directiveDefinition: MutableDirectiveDefinition directiveArgumentDefinition: MutableArgumentDefinition, - directive: MutableDirective + directive: MutableDirective, + inputTypeReferencer: MutableInputTypeReferencer, + outputTypeReferencer: MutableOutputTypeReferencer, + objectTypeReferencer: MutableObjectTypeReferencer, } type World = ImmutableWorld | MutableWorld; @@ -80,15 +88,23 @@ export type OutputType = ScalarType | ObjectType | UnionType | ListType; export type InputType = ScalarType | InputObjectType; export type WrapperType = ListType; +export type OutputTypeReferencer = FieldDefinition; +export type InputTypeReferencer = InputFieldDefinition | ArgumentDefinition; +export type ObjectTypeReferencer = OutputType | UnionType | SchemaDefinition; + export type MutableType = MutableOutputType | MutableInputType; export type MutableNamedType = MutableScalarType | MutableObjectType | MutableUnionType | MutableInputObjectType; export type MutableOutputType = MutableScalarType | MutableObjectType | MutableUnionType | MutableListType; export type MutableInputType = MutableScalarType | MutableInputObjectType; export type MutableWrapperType = MutableListType; +export type MutableOutputTypeReferencer = MutableFieldDefinition; +export type MutableInputTypeReferencer = MutableInputFieldDefinition | MutableArgumentDefinition; +export type MutableObjectTypeReferencer = MutableOutputType | MutableUnionType | MutableSchemaDefinition; + // Those exists to make it a bit easier to write code that work on both mutable and immutable variants, if one so wishes. export type AnySchema = Schema | MutableSchema; -export type AnySchemaElement = SchemaElement | MutableSchemaElement; +export type AnySchemaElement = SchemaElement | MutableSchemaElement; export type AnyType = AnyOutputType | AnyInputType; export type AnyNamedType = AnyScalarType | AnyObjectType | AnyUnionType | AnyInputObjectType; export type AnyOutputType = AnyScalarType | AnyObjectType | AnyUnionType | AnyListType; @@ -109,8 +125,6 @@ export type AnyFieldArgumentDefinition = ArgumentDefinition | M export type AnyDirectiveArgumentDefinition = ArgumentDefinition | MutableArgumentDefinition; export type AnyArgumentDefinition = AnyFieldDefinition | AnyDirectiveDefinition; -const builtInTypes = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; -const builtInDirectives = [ 'include', 'skip', 'deprecated', 'specifiedBy' ]; export function isNamedType(type: W['type']): type is W['namedType'] { return type instanceof BaseNamedType; @@ -120,12 +134,31 @@ export function isWrapperType(type: W['type']): type is W['wrap return type.kind == 'ListType'; } -export function isBuiltInType(type: W['namedType']): boolean { - return builtInTypes.includes(type.name); +export function isOutputType(type: W['type']): type is W['outputType'] { + if (isWrapperType(type)) { + return isOutputType(type.baseType()); + } + switch (type.kind) { + case 'ScalarType': + case 'ObjectType': + case 'UnionType': + return true; + default: + return false; + } } -export function isBuiltInDirective(directive: W['directiveDefinition']): boolean { - return builtInDirectives.includes(directive.name); +export function isInputType(type: W['type']): type is W['inputType'] { + if (isWrapperType(type)) { + return isInputType(type.baseType()); + } + switch (type.kind) { + case 'ScalarType': + case 'InputObjectType': + return true; + default: + return false; + } } export interface Named { @@ -150,8 +183,8 @@ export interface SchemaElement { appliedDirective(name: string): W['directive'][]; } -export interface MutableSchemaElement extends SchemaElement { - remove(): MutableSchemaElement[]; +export interface MutableSchemaElement extends SchemaElement { + remove(): R[]; } abstract class BaseElement

implements SchemaElement { @@ -223,6 +256,7 @@ abstract class BaseNamedType extends BaseNamedElement extends BaseNamedElement extends BaseNamedElement { private _schemaDefinition: W['schemaDefinition'] | undefined = undefined; - protected readonly builtInTypes: Map = new Map(); + protected readonly builtInTypes: Map = new Map(); protected readonly typesMap: Map = new Map(); + protected readonly builtInDirectives: Map = new Map(); protected readonly directivesMap: Map = new Map(); - protected constructor() {} + protected constructor(readonly builtIns: BuiltIns, ctors: Ctors) { + const thisSchema = this as any; + // BuiltIn types can depend on each other, so we still want to do the 2-phase copy. + for (const builtInType of builtIns.builtInTypes()) { + const type = copyNamedTypeShallow(builtInType, thisSchema, builtIns, ctors); + this.builtInTypes.set(type.name, type); + } + for (const builtInType of builtIns.builtInTypes()) { + copyNamedTypeInner(builtInType, this.type(builtInType.name)!, ctors); + } + for (const builtInDirective of builtIns.builtInDirectives()) { + const directive = copyDirectiveDefinition(builtInDirective, thisSchema, builtIns, ctors); + this.builtInDirectives.set(directive.name, directive); + } + } kind: 'Schema' = 'Schema'; @@ -261,10 +317,6 @@ class BaseSchema { this._schemaDefinition = schemaDefinition; } - private setBuiltIn(name: string, type: W['scalarType']) { - this.builtInTypes.set(name, type); - } - get schemaDefinition(): W['schemaDefinition'] { assert(this._schemaDefinition, "Badly constructed schema; doesn't have a schema definition"); return this._schemaDefinition; @@ -286,23 +338,23 @@ class BaseSchema { } intType(): W['scalarType'] { - return this.builtInTypes.get('Int')!; + return this.builtInTypes.get('Int')! as W['scalarType']; } floatType(): W['scalarType'] { - return this.builtInTypes.get('Float')!; + return this.builtInTypes.get('Float')! as W['scalarType']; } stringType(): W['scalarType'] { - return this.builtInTypes.get('String')!; + return this.builtInTypes.get('String')! as W['scalarType']; } booleanType(): W['scalarType'] { - return this.builtInTypes.get('Boolean')!; + return this.builtInTypes.get('Boolean')! as W['scalarType']; } idType(): W['scalarType'] { - return this.builtInTypes.get('ID')!; + return this.builtInTypes.get('ID')! as W['scalarType']; } get directives(): ReadonlyMap { @@ -310,7 +362,8 @@ class BaseSchema { } directive(name: string): W['directiveDefinition'] | undefined { - return this.directivesMap.get(name); + const directive = this.directivesMap.get(name); + return directive ? directive : this.builtInDirectives.get(name); } *allSchemaElement(): Generator { @@ -336,24 +389,24 @@ export class Schema extends BaseSchema { // just return `this`) for some reason, so the field is a bit clearer/safer). mutable: false = false; - static parse(source: string | Source): Schema { - return buildSchemaInternal(parse(source), Ctors.immutable); + static parse(source: string | Source, builtIns: BuiltIns = graphQLBuiltIns): Schema { + return buildSchemaInternal(parse(source), builtIns, Ctors.immutable); } - toMutable(): MutableSchema { - return copy(this, Ctors.mutable); + toMutable(builtIns?: BuiltIns): MutableSchema { + return copy(this, builtIns ?? this.builtIns, Ctors.mutable); } } export class MutableSchema extends BaseSchema { mutable: true = true; - static empty(): MutableSchema { - return Ctors.mutable.addSchemaDefinition(Ctors.mutable.schema()); + static empty(builtIns: BuiltIns = graphQLBuiltIns): MutableSchema { + return Ctors.mutable.addSchemaDefinition(Ctors.mutable.schema(builtIns)); } - static parse(source: string | Source): MutableSchema { - return buildSchemaInternal(parse(source), Ctors.mutable); + static parse(source: string | Source, builtIns: BuiltIns = graphQLBuiltIns): MutableSchema { + return buildSchemaInternal(parse(source), builtIns, Ctors.mutable); } private ensureTypeNotFound(name: string) { @@ -387,7 +440,7 @@ export class MutableSchema extends BaseSchema { } addObjectType(name: string): MutableObjectType { - return this.addType(name, n => Ctors.mutable.createObjectType(n, this)); + return this.addType(name, n => Ctors.mutable.createObjectType(n, this, false)); } addOrGetScalarType(name: string): MutableScalarType { @@ -398,15 +451,15 @@ export class MutableSchema extends BaseSchema { if (this.builtInTypes.has(name)) { throw new GraphQLError(`Cannot add scalar type of name ${name} as it is a built-in type`); } - return this.addType(name, n => Ctors.mutable.createScalarType(n, this)); + return this.addType(name, n => Ctors.mutable.createScalarType(n, this, false)); } addDirective(directive: MutableDirectiveDefinition) { this.directivesMap.set(directive.name, directive); } - toImmutable(): Schema { - return copy(this, Ctors.immutable); + toImmutable(builtIns?: BuiltIns): Schema { + return copy(this, builtIns ?? this.builtIns, Ctors.immutable); } } @@ -447,7 +500,7 @@ export class SchemaDefinition extends BaseElem } } -export class MutableSchemaDefinition extends SchemaDefinition implements MutableSchemaElement { +export class MutableSchemaDefinition extends SchemaDefinition implements MutableSchemaElement { setRoot(rootType: SchemaRoot, objectType: MutableObjectType): MutableObjectType { if (objectType.schema() != this.schema()) { const attachement = objectType.schema() ? 'attached to another schema' : 'detached'; @@ -461,8 +514,20 @@ export class MutableSchemaDefinition extends SchemaDefinition impl return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); } - remove(): MutableSchemaElement[] { - throw new Error('TODO'); + remove(): never[] { + if (!this._parent) { + return []; + } + // We don't want to leave the schema with a SchemaDefinition, so we create an empty one. Note that since we essentially + // clear this one so we could leave it (one exception is the source which we don't bother cleaning). But it feels + // more consistent not to, so that a schemaElement is consistently always detached after a remove()). + Ctors.mutable.addSchemaDefinition(this._parent); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); + } + // There can be no other referencers than the parent schema. + return []; } } @@ -474,11 +539,8 @@ export class ScalarType extends BaseNamedType< } } -export class MutableScalarType extends ScalarType implements MutableSchemaElement { +export class MutableScalarType extends ScalarType implements MutableSchemaElement { applyDirective(name: string, args?: Map): MutableDirective { - if (builtInTypes.includes(this.name)) { - throw Error(`Cannot apply directive to built-in type ${this.name}`); - } return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); } @@ -495,7 +557,7 @@ export class MutableScalarType extends ScalarType implements Mutab * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ - remove(): MutableSchemaElement[] { + remove(): (MutableOutputTypeReferencer | MutableInputTypeReferencer)[] { if (!this._parent) { return []; } @@ -506,7 +568,7 @@ export class MutableScalarType extends ScalarType implements Mutab } const toReturn = [... this._referencers].map(r => { BaseElement.prototype['removeTypeReference'].call(r, this); - return r; + return r as MutableOutputTypeReferencer | MutableInputTypeReferencer; }); this._referencers.clear(); return toReturn; @@ -519,9 +581,10 @@ export class ObjectType extends BaseNamedType< protected constructor( name: string, schema: W['schema'] | undefined, + isBuiltIn: boolean, source?: ASTNode ) { - super(name, schema, source); + super(name, schema, isBuiltIn, source); } kind: 'ObjectType' = 'ObjectType'; @@ -546,8 +609,11 @@ export class ObjectType extends BaseNamedType< } } -export class MutableObjectType extends ObjectType implements MutableSchemaElement { - addField(name: string, type: MutableOutputType): MutableFieldDefinition { +export class MutableObjectType extends ObjectType implements MutableSchemaElement { + addField(name: string, type: MutableType): MutableFieldDefinition { + if (this.isBuiltIn) { + throw Error(`Cannot add field to built-in type ${this.name}`); + } if (this.field(name)) { throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); } @@ -555,6 +621,9 @@ export class MutableObjectType extends ObjectType implements Mutab const attachement = type.schema() ? 'attached to another schema' : 'detached'; throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this schema (it is ${attachement})`); } + if (!isOutputType(type)) { + throw new GraphQLError(`Cannot use type ${type} for field ${name} as it is an input type (fields can only use output types)`); + } const field = Ctors.mutable.createFieldDefinition(name, this, type); this.fieldsMap.set(name, field); return field; @@ -577,7 +646,7 @@ export class MutableObjectType extends ObjectType implements Mutab * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ - remove(): MutableSchemaElement[] { + remove(): MutableObjectTypeReferencer[] { if (!this._parent) { return []; } @@ -591,7 +660,7 @@ export class MutableObjectType extends ObjectType implements Mutab } const toReturn = [... this._referencers].map(r => { BaseElement.prototype['removeTypeReference'].call(r, this); - return r; + return r as MutableObjectTypeReferencer; }); this._referencers.clear(); return toReturn; @@ -604,9 +673,10 @@ export class UnionType extends BaseNamedType extends BaseNamedType implements MutableSchemaElement { +export class MutableUnionType extends UnionType implements MutableSchemaElement { addType(type: MutableObjectType): void { + if (this.isBuiltIn) { + throw Error(`Cannot modify built-in type ${this.name}`); + } if (type.schema() != this.schema()) { const attachement = type.schema() ? 'attached to another schema' : 'detached'; throw new GraphQLError(`Cannot add provided type ${type} to union ${this} as it is not attached to this schema (it is ${attachement})`); @@ -651,7 +724,7 @@ export class MutableUnionType extends UnionType implements Mutable * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ - remove(): MutableSchemaElement[] { + remove(): MutableOutputTypeReferencer[] { if (!this._parent) { return []; } @@ -663,7 +736,7 @@ export class MutableUnionType extends UnionType implements Mutable this.typesList.splice(0, this.typesList.length); const toReturn = [... this._referencers].map(r => { BaseElement.prototype['removeTypeReference'].call(r, this); - return r; + return r as MutableOutputTypeReferencer; }); this._referencers.clear(); return toReturn; @@ -676,9 +749,10 @@ export class InputObjectType extends BaseNamed protected constructor( name: string, schema: W['schema'] | undefined, + isBuiltIn: boolean, source?: ASTNode ) { - super(name, schema, source); + super(name, schema, isBuiltIn, source); } kind: 'InputObjectType' = 'InputObjectType'; @@ -700,8 +774,11 @@ export class InputObjectType extends BaseNamed } } -export class MutableInputObjectType extends InputObjectType implements MutableSchemaElement { +export class MutableInputObjectType extends InputObjectType implements MutableSchemaElement { addField(name: string, type: MutableInputType): MutableInputFieldDefinition { + if (this.isBuiltIn) { + throw Error(`Cannot modify built-in type ${this.name}`); + } if (this.field(name)) { throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); } @@ -731,7 +808,7 @@ export class MutableInputObjectType extends InputObjectType implem * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ - remove(): MutableSchemaElement[] { + remove(): MutableInputTypeReferencer[] { if (!this._parent) { return []; } @@ -745,7 +822,7 @@ export class MutableInputObjectType extends InputObjectType implem } const toReturn = [... this._referencers].map(r => { BaseElement.prototype['removeTypeReference'].call(r, this); - return r; + return r as MutableInputTypeReferencer; }); this._referencers.clear(); return toReturn; @@ -821,7 +898,7 @@ export class FieldDefinition extends BaseNamed } } -export class MutableFieldDefinition extends FieldDefinition implements MutableSchemaElement { +export class MutableFieldDefinition extends FieldDefinition implements MutableSchemaElement { setType(type: MutableOutputType): MutableFieldDefinition { if (!this.schema()) { // Let's not allow manipulating detached elements too much as this could make our lives harder. @@ -845,7 +922,7 @@ export class MutableFieldDefinition extends FieldDefinition implem * After calling this method, this field definition will be "detached": it wil have no parent, schema, type, * arguments or directives. */ - remove(): MutableSchemaElement[] { + remove(): never[] { if (!this._parent) { return []; } @@ -894,7 +971,7 @@ export class InputFieldDefinition extends Base } } -export class MutableInputFieldDefinition extends InputFieldDefinition implements MutableSchemaElement { +export class MutableInputFieldDefinition extends InputFieldDefinition implements MutableSchemaElement { setType(type: MutableInputType): MutableInputFieldDefinition { if (!this.schema()) { // Let's not allow manipulating detached elements too much as this could make our lives harder. @@ -918,7 +995,7 @@ export class MutableInputFieldDefinition extends InputFieldDefinition extends ArgumentDefinition implements MutableSchemaElement { +export class MutableArgumentDefinition

extends ArgumentDefinition implements MutableSchemaElement { applyDirective(name: string, args?: Map): MutableDirective { return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); } @@ -981,7 +1058,7 @@ export class MutableArgumentDefinition

extends BaseNamedElement { protected readonly _args: Map = new Map(); + protected _repeatable: boolean = false; + protected readonly _locations: DirectiveLocationEnum[] = []; + protected readonly _referencers: Set = new Set(); protected constructor( name: string, schema: W['schema'] | W['detached'], + readonly isBuiltIn: boolean, source?: ASTNode ) { super(name, schema, source); @@ -1009,7 +1090,7 @@ export class DirectiveDefinition extends BaseN kind: 'Directive' = 'Directive'; coordinate(): string { - throw new Error('TODO'); + return `@{this.name}`; } arguments(): ReadonlyMap { @@ -1020,17 +1101,37 @@ export class DirectiveDefinition extends BaseN return this._args.get(name); } + get repeatable(): boolean { + return this._repeatable; + } + + get locations(): readonly DirectiveLocationEnum[] { + return this._locations; + } + protected removeTypeReference(_: W['namedType']): void { assert(false, "Directive definitions can never reference other types directly (their arguments might)"); } + private addReferencer(referencer: W['directive']) { + assert(referencer, 'Referencer should exists'); + this._referencers.add(referencer); + } + + protected setRepeatableInternal(repeatable: boolean) { + this._repeatable = repeatable; + } + toString(): string { return this.name; } } -export class MutableDirectiveDefinition extends DirectiveDefinition implements MutableSchemaElement { +export class MutableDirectiveDefinition extends DirectiveDefinition implements MutableSchemaElement { addArgument(name: string, type: MutableInputType, defaultValue?: any): MutableArgumentDefinition { + if (this.isBuiltIn) { + throw Error(`Cannot modify built-in directive ${this.name}`); + } if (!this.schema()) { // Let's not allow manipulating detached elements too much as this could make our lives harder. throw new GraphQLError(`Cannot add argument to directive definition ${this.name} as it is detached`); @@ -1044,8 +1145,56 @@ export class MutableDirectiveDefinition extends DirectiveDefinition= 0) { + this._locations.splice(index, 1); + } + } + return this; + } + + remove(): MutableDirective[] { + if (!this._parent) { + return []; + } + removeDirectiveDefinition(this, this._parent); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); + } + for (const arg of this._args.values()) { + arg.remove(); + } + // Note that directive applications don't link directly to their definitions. Instead, we fetch + // their definition from the schema when rquested. So we don't have to do anything on the referencers + // other than return them. + const toReturn = [... this._referencers]; + this._referencers.clear(); + return toReturn; } } @@ -1125,18 +1274,18 @@ class Ctors { // are protected, but we still need to access them here, hence the `Function.prototype` hack. // Note: this is fairly well contained so manageable but certainly ugly and a bit error-prone, so if someone knowns a better way? static immutable = new Ctors( - () => new (Function.prototype.bind.call(Schema, null)), + (builtIns, ctors) => new (Function.prototype.bind.call(Schema, null, builtIns, ctors)), (parent, source) => new (Function.prototype.bind.call(SchemaDefinition, null, parent, source)), - (name, doc, source) => new (Function.prototype.bind.call(ScalarType, null, name, doc, source)), - (name, doc, source) => new (Function.prototype.bind.call(ObjectType, null, name, doc, source)), - (name, doc, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, source)), - (name, doc, source) => new (Function.prototype.bind.call(InputObjectType, null, name, doc, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(ScalarType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(ObjectType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(InputObjectType, null, name, doc, builtIn, source)), (type) => new (Function.prototype.bind.call(ListType, null, type)), (name, parent, type, source) => new (Function.prototype.bind.call(FieldDefinition, null, name, parent, type, source)), (name, parent, type, source) => new (Function.prototype.bind.call(InputFieldDefinition, null, name, parent, type, source)), (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), - (name, parent, source) => new (Function.prototype.bind.call(DirectiveDefinition, null, name, parent, source)), + (name, parent, builtIn, source) => new (Function.prototype.bind.call(DirectiveDefinition, null, name, parent, builtIn, source)), (name, parent, args, source) => new (Function.prototype.bind.call(Directive, null, name, parent, args, source)), (v) => { if (v == undefined) @@ -1147,46 +1296,42 @@ class Ctors { ); static mutable = new Ctors( - () => new (Function.prototype.bind.call(MutableSchema, null)), + (builtIns, ctors) => new (Function.prototype.bind.call(MutableSchema, null, builtIns, ctors)), (parent, source) => new (Function.prototype.bind.call(MutableSchemaDefinition, null, parent, source)), - (name, doc, source) => new (Function.prototype.bind.call(MutableScalarType, null, name, doc, source)), - (name, doc, source) => new (Function.prototype.bind.call(MutableObjectType, null, name, doc, source)), - (name, doc, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, source)), - (name, doc, source) => new (Function.prototype.bind.call(MutableInputObjectType, null, name, doc, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableScalarType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableObjectType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableInputObjectType, null, name, doc, builtIn, source)), (type) => new (Function.prototype.bind.call(MutableListType, null, type)), (name, parent, type, source) => new (Function.prototype.bind.call(MutableFieldDefinition, null, name, parent, type, source)), (name, parent, type, source) => new (Function.prototype.bind.call(MutableInputFieldDefinition, null, name, parent, type, source)), (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), - (name, parent, source) => new (Function.prototype.bind.call(MutableDirectiveDefinition, null, name, parent, source)), + (name, parent, builtIn, source) => new (Function.prototype.bind.call(MutableDirectiveDefinition, null, name, parent, builtIn, source)), (name, parent, args, source) => new (Function.prototype.bind.call(MutableDirective, null, name, parent, args, source)), (v) => v ); constructor( - private readonly createSchema: () => W['schema'], + private readonly createSchema: (builtIns: BuiltIns, ctors: Ctors) => W['schema'], private readonly createSchemaDefinition: (parent: W['schema'] | W['detached'], source?: ASTNode) => W['schemaDefinition'], - readonly createScalarType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['scalarType'], - readonly createObjectType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['objectType'], - readonly createUnionType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['unionType'], - readonly createInputObjectType: (name: string, schema: W['schema'] | W['detached'], source?: ASTNode) => W['inputObjectType'], + readonly createScalarType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['scalarType'], + readonly createObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['objectType'], + readonly createUnionType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['unionType'], + readonly createInputObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['inputObjectType'], readonly createList: (type: T) => W['listType'], readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], readonly createInputFieldDefinition: (name: string, parent: W['inputObjectType'] | W['detached'], type: W['inputType'], source?: ASTNode) => W['inputFieldDefinition'], readonly createFieldArgumentDefinition: (name: string, parent: W['fieldDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['fieldArgumentDefinition'], readonly createDirectiveArgumentDefinition: (name: string, parent: W['directiveDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['directiveArgumentDefinition'], - readonly createDirectiveDefinition: (name: string, parent: W['schema'] | W['detached'], source?: ASTNode) => W['directiveDefinition'], + readonly createDirectiveDefinition: (name: string, parent: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['directiveDefinition'], readonly createDirective: (name: string, parent: W['schemaElement'] | W['detached'], args: Map, source?: ASTNode) => W['directive'], readonly checkDetached: (v: T | undefined) => T | W['detached'] ) { } - schema(): W['schema'] { - const doc = this.createSchema(); - for (const builtIn of builtInTypes) { - BaseSchema.prototype['setBuiltIn'].call(doc, builtIn, this.createScalarType(builtIn, doc)); - } - return doc; + schema(builtIns: BuiltIns) { + return this.createSchema(builtIns, this); } addSchemaDefinition(schema: W['schema'], source?: ASTNode): W['schema'] { @@ -1195,27 +1340,97 @@ class Ctors { return schema; } - createNamedType(kind: string, name: string, schema: W['schema'], source?: ASTNode): W['namedType'] { + createNamedType(kind: string, name: string, schema: W['schema'], isBuiltIn: boolean, source?: ASTNode): W['namedType'] { switch (kind) { case 'ScalarType': - return this.createScalarType(name, schema, source); + return this.createScalarType(name, schema, isBuiltIn, source); case 'ObjectType': - return this.createObjectType(name, schema, source); + return this.createObjectType(name, schema, isBuiltIn, source); case 'UnionType': - return this.createUnionType(name, schema, source); + return this.createUnionType(name, schema, isBuiltIn, source); case 'InputObjectType': - return this.createInputObjectType(name, schema, source); + return this.createInputObjectType(name, schema, isBuiltIn, source); default: assert(false, "Missing branch for type " + kind); } } } +export class BuiltIns { + private readonly defaultGraphQLBuiltInTypes: readonly string[] = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; + private readonly _builtInTypes = new Map(); + private readonly _builtInDirectives = new Map(); + + constructor() { + this.populateBuiltInTypes(); + this.populateBuiltInDirectives(); + } + + isBuiltInType(name: string) { + return this._builtInTypes.has(name);; + } + + isBuiltInDirective(name: string) { + return this._builtInDirectives.has(name); + } + + builtInTypes(): IterableIterator { + return this._builtInTypes.values(); + } + + builtInDirectives(): IterableIterator { + return this._builtInDirectives.values(); + } + + protected populateBuiltInTypes(): void { + this.defaultGraphQLBuiltInTypes.forEach(t => this.addScalarType(t)) + } + + protected populateBuiltInDirectives(): void { + // TODO: add arguments and locations + this.addDirective('include'); + this.addDirective('skip'); + this.addDirective('deprecated'); + this.addDirective('specifiedBy'); + } + + protected getType(name: string): MutableNamedType { + const type = this._builtInTypes.get(name); + assert(type, `Cannot find built-in type ${name}`); + return type; + } + + private addType(type: T): T { + this._builtInTypes.set(type.name, type); + return type; + } + + protected addScalarType(name: string): MutableScalarType { + return this.addType(Ctors.mutable.createScalarType(name, undefined, true)); + } + + protected addObjectType(name: string): MutableObjectType { + return this.addType(Ctors.mutable.createObjectType(name, undefined, true)); + } + + protected addDirective(name: string): MutableDirectiveDefinition { + const directive = Ctors.mutable.createDirectiveDefinition(name, undefined, true); + this._builtInDirectives.set(directive.name, directive); + return directive; + } +} + +export const graphQLBuiltIns = new BuiltIns(); + + function addTypeDefinition(namedType: W['namedType'], schema: W['schema']) { (schema.types as Map).set(namedType.name, namedType); } function removeTypeDefinition(namedType: W['namedType'], schema: W['schema']) { + if (namedType.isBuiltIn) { + throw Error(`Cannot remove built-in type ${namedType.name}`); + } (schema.types as Map).delete(namedType.name); } @@ -1223,6 +1438,13 @@ function addDirectiveDefinition(definition: W['directiveDefinit (schema.directives as Map).set(definition.name, definition); } +function removeDirectiveDefinition(definition: W['directiveDefinition'], schema: W['schema']) { + if (definition.isBuiltIn) { + throw Error(`Cannot remove built-in directive ${definition.name}`); + } + (schema.directives as Map).delete(definition.name); +} + function addRoot(root: SchemaRoot, typeName: string, schemaDefinition: W['schemaDefinition']) { const type = schemaDefinition.schema()!.type(typeName)! as W['objectType']; (schemaDefinition.roots as Map).set(root, type); @@ -1258,6 +1480,15 @@ function addReferencerToType(referencer: W['schemaElement'], ty } } +function addReferencerToDirectiveDefinition(referencer: W['directive'], definition: W['directiveDefinition']) { + DirectiveDefinition.prototype['addReferencer'].call(definition, referencer); +} + +function setDirectiveDefinitionRepeatableAndLocations(definition: W['directiveDefinition'], repeatable: boolean, locations: readonly DirectiveLocationEnum[]) { + DirectiveDefinition.prototype['setRepeatableInternal'].call(definition, repeatable); + (definition.locations as DirectiveLocationEnum[]).push(...locations); +} + function buildValue(value?: ValueNode): any { // TODO: Should we rewrite a version of valueFromAST instead of using valueFromASTUntyped? Afaict, what we're missing out on is // 1) coercions, which concretely, means: @@ -1270,9 +1501,9 @@ function buildValue(value?: ValueNode): any { return value ? valueFromASTUntyped(value) : undefined; } -function buildSchemaInternal(documentNode: DocumentNode, ctors: Ctors): W['schema'] { - const doc = ctors.schema(); - buildNamedTypeShallow(documentNode, doc, ctors); +function buildSchemaInternal(documentNode: DocumentNode, builtIns: BuiltIns, ctors: Ctors): W['schema'] { + const doc = ctors.schema(builtIns); + buildNamedTypeAndDirectivesShallow(documentNode, doc, ctors); for (const definitionNode of documentNode.definitions) { switch (definitionNode.kind) { case 'OperationDefinition': @@ -1290,7 +1521,7 @@ function buildSchemaInternal(documentNode: DocumentNode, ctors: buildNamedTypeInner(definitionNode, doc.type(definitionNode.name.value)!, ctors); break; case 'DirectiveDefinition': - addDirectiveDefinition(buildDirectiveDefinition(definitionNode, doc, ctors), doc); + buildDirectiveDefinitionInner(definitionNode, doc.directive(definitionNode.name.value)!, ctors); break; case 'SchemaExtension': case 'ScalarTypeExtension': @@ -1305,7 +1536,7 @@ function buildSchemaInternal(documentNode: DocumentNode, ctors: return doc; } -function buildNamedTypeShallow(documentNode: DocumentNode, schema: W['schema'], ctors: Ctors) { +function buildNamedTypeAndDirectivesShallow(documentNode: DocumentNode, schema: W['schema'], ctors: Ctors) { for (const definitionNode of documentNode.definitions) { switch (definitionNode.kind) { case 'ScalarTypeDefinition': @@ -1314,7 +1545,7 @@ function buildNamedTypeShallow(documentNode: DocumentNode, sche case 'UnionTypeDefinition': case 'EnumTypeDefinition': case 'InputObjectTypeDefinition': - addTypeDefinition(ctors.createNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value, schema, definitionNode), schema); + addTypeDefinition(ctors.createNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value, schema, false, definitionNode), schema); break; case 'SchemaExtension': case 'ScalarTypeExtension': @@ -1324,6 +1555,9 @@ function buildNamedTypeShallow(documentNode: DocumentNode, sche case 'EnumTypeExtension': case 'InputObjectTypeExtension': throw new Error("Extensions are a TODO"); + case 'DirectiveDefinition': + addDirectiveDefinition(ctors.createDirectiveDefinition(definitionNode.name.value, schema, false, definitionNode), schema); + break; } } } @@ -1353,7 +1587,13 @@ function buildDirective(directiveNode: DirectiveNode, element: for (const argNode of directiveNode.arguments ?? []) { args.set(argNode.name.value, buildValue(argNode.value)); } - return ctors.createDirective(directiveNode.name.value, element, args, directiveNode); + const directive = ctors.createDirective(directiveNode.name.value, element, args, directiveNode); + const definition = directive.definition(); + if (!definition) { + throw new GraphQLError(`Unknown directive "@${directive.name}".`, directiveNode); + } + addReferencerToDirectiveDefinition(directive, definition); + return directive; } function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives, type: W['namedType'], ctors: Ctors) { @@ -1422,12 +1662,12 @@ function buildInputFieldDefinition(fieldNode: InputValueDefinit return builtField; } -function buildDirectiveDefinition(directiveNode: DirectiveDefinitionNode, parent: W['schema'], ctors: Ctors): W['directiveDefinition'] { - const builtDirective = ctors.createDirectiveDefinition(directiveNode.name.value, parent, directiveNode); +function buildDirectiveDefinitionInner(directiveNode: DirectiveDefinitionNode, directive: W['directiveDefinition'], ctors: Ctors) { for (const inputValueDef of directiveNode.arguments ?? []) { - addDirectiveArg(buildDirectiveArgumentDefinition(inputValueDef, builtDirective, ctors), builtDirective); + addDirectiveArg(buildDirectiveArgumentDefinition(inputValueDef, directive, ctors), directive); } - return builtDirective; + const locations = directiveNode.locations.map(({ value }) => value as DirectiveLocationEnum); + setDirectiveDefinitionRepeatableAndLocations(directive, directiveNode.repeatable, locations); } function buildDirectiveArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['directiveDefinition'], ctors: Ctors): W['directiveArgumentDefinition'] { @@ -1438,17 +1678,37 @@ function buildDirectiveArgumentDefinition(inputNode: InputValue return built; } -function copy(source: WS['schema'], destCtors: Ctors): WD['schema'] { - const doc = destCtors.addSchemaDefinition(destCtors.schema(), source.schemaDefinition.source()); +function copy(source: WS['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['schema'] { + const doc = destCtors.addSchemaDefinition(destCtors.schema(destBuiltIns), source.schemaDefinition.source()); for (const type of source.types.values()) { - addTypeDefinition(copyNamedTypeShallow(type, doc, destCtors), doc); + addTypeDefinition(copyNamedTypeShallow(type, doc, destBuiltIns, destCtors), doc); + } + for (const directive of source.directives.values()) { + addDirectiveDefinition(copyDirectiveDefinition(directive, doc, destBuiltIns, destCtors), doc); + } + if (destBuiltIns != source.builtIns) { + // Any type/directive that is a built-in in the source but not the destination must be copied as a normal definition. + for (const builtInType of source.builtIns.builtInTypes()) { + if (!destBuiltIns.isBuiltInType(builtInType.name)) { + addTypeDefinition(copyNamedTypeShallow(builtInType, doc, destBuiltIns, destCtors), doc); + } + } + for (const builtInDirective of source.builtIns.builtInDirectives()) { + if (!destBuiltIns.isBuiltInDirective(builtInDirective.name)) { + addDirectiveDefinition(copyDirectiveDefinition(builtInDirective, doc, destBuiltIns, destCtors), doc); + } + } } copySchemaDefinitionInner(source.schemaDefinition, doc.schemaDefinition, destCtors); for (const type of source.types.values()) { copyNamedTypeInner(type, doc.type(type.name)!, destCtors); } - for (const directive of source.directives.values()) { - addDirectiveDefinition(copyDirectiveDefinition(directive, doc, destCtors), doc); + if (destBuiltIns != source.builtIns) { + for (const builtInType of source.builtIns.builtInTypes()) { + if (!destBuiltIns.isBuiltInType(builtInType.name)) { + copyNamedTypeInner(builtInType, doc.type(builtInType.name)!, destCtors); + } + } } return doc; } @@ -1471,13 +1731,19 @@ function copyDirective(source: WS['directive for (const [name, value] of source.arguments.entries()) { args.set(name, value); } - return destCtors.createDirective(source.name, parentDest, args, source.source); + const directive = destCtors.createDirective(source.name, parentDest, args, source.source); + const definition = directive.definition(); + if (!definition) { + throw new GraphQLError(`Unknown directive "@${directive.name}" applied to ${parentDest}.`); + } + addReferencerToDirectiveDefinition(directive, definition); + return directive; } // Because types can refer to one another (through fields or directive applications), we first create a shallow copy of // all types, and then copy fields (see below) assuming that the type "shell" exists. -function copyNamedTypeShallow(source: WS['namedType'], schema: WD['schema'], destCtors: Ctors): WD['namedType'] { - return destCtors.createNamedType(source.kind, source.name, schema, source.source()); +function copyNamedTypeShallow(source: WS['namedType'], schema: WD['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['namedType'] { + return destCtors.createNamedType(source.kind, source.name, schema, destBuiltIns.isBuiltInType(source.name), source.source()); } function copyNamedTypeInner(source: WS['namedType'], dest: WD['namedType'], destCtors: Ctors) { @@ -1545,11 +1811,12 @@ function copyFieldArgumentDefinition(source: return copied; } -function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['schema'], destCtors: Ctors): WD['directiveDefinition'] { - const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, source.source()); +function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['directiveDefinition'] { + const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, destBuiltIns.isBuiltInDirective(source.name), source.source()); for (const sourceArg of source.arguments().values()) { addDirectiveArg(copyDirectiveArgumentDefinition(sourceArg, copiedDirective, destCtors), copiedDirective); } + setDirectiveDefinitionRepeatableAndLocations(copiedDirective, source.repeatable, source.locations); return copiedDirective; } function copyDirectiveArgumentDefinition(source: WS['directiveArgumentDefinition'], destParent: WD['directiveDefinition'], destCtors: Ctors): WD['directiveArgumentDefinition'] { diff --git a/core-js/src/federation.ts b/core-js/src/federation.ts index 905272627..93a89c708 100644 --- a/core-js/src/federation.ts +++ b/core-js/src/federation.ts @@ -1,2 +1,22 @@ -export const federationMachineryTypesNames = [ '_Entity', '_Service', '_Any' ]; -export const federationDirectivesNames = [ 'key', 'extends', 'external', 'requires', 'provides' ]; +import { BuiltIns } from "./definitions"; + +export class FederationBuiltIns extends BuiltIns { + protected createBuiltInTypes(): void { + super.populateBuiltInTypes(); + // TODO: add Entity, which is a union (initially empty, populated later) + //this.addUnionType('_Entity'); + this.addObjectType('_Service').addField('sdl', this.getType('String')); + this.addScalarType('_Any'); + } + + protected populateBuiltInDirectives(): void { + this.addDirective('key'); + this.addDirective('extends'); + this.addDirective('external'); + this.addDirective('requires'); + this.addDirective('provides'); + this.addDirective('inaccessible'); + } +} + +export const federationBuiltIns = new FederationBuiltIns(); diff --git a/core-js/src/print.ts b/core-js/src/print.ts index b474f6887..ff41a4907 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -11,37 +11,16 @@ import { AnySchemaDefinition, AnySchemaElement, AnyUnionType, - defaultRootTypeName, - isBuiltInDirective, - isBuiltInType + defaultRootTypeName } from "./definitions"; -import { federationMachineryTypesNames, federationDirectivesNames } from "./federation"; const indent = " "; // Could be made an option at some point -const defaultTypeFilter = (type: AnyNamedType) => ( - !isBuiltInType(type) && - !federationMachineryTypesNames.includes(type.name) -); - -const defaultDirectiveFilter = (directive: AnyDirectiveDefinition) => ( - !isBuiltInDirective(directive) && - !federationDirectivesNames.includes(directive.name) -); - export function printSchema(schema: AnySchema): string { - return printFilteredSchema(schema, defaultTypeFilter, defaultDirectiveFilter); -} - -function printFilteredSchema( - schema: AnySchema, - typeFilter: (type: AnyNamedType) => boolean, - directiveFilter: (type: AnyDirectiveDefinition) => boolean -): string { - const directives = [...schema.directives.values()].filter(directiveFilter); + const directives = [...schema.directives.values()].filter(d => !d.isBuiltIn); const types = [...schema.types.values()] - .sort((type1, type2) => type1.name.localeCompare(type2.name)) - .filter(typeFilter); + .filter(t => !t.isBuiltIn) + .sort((type1, type2) => type1.name.localeCompare(type2.name)); return ( [printSchemaDefinition(schema.schemaDefinition)] .concat( @@ -58,7 +37,7 @@ function printSchemaDefinition(schemaDefinition: AnySchemaDefinition): string | return; } const rootEntries = [...schemaDefinition.roots.entries()].map(([root, type]) => `${indent}${root}: ${type}`); - return `schema {\n${rootEntries.join('\n')}\n}`; + return `schema${printAppliedDirectives(schemaDefinition)} {\n${rootEntries.join('\n')}\n}`; } /** @@ -74,6 +53,9 @@ function printSchemaDefinition(schemaDefinition: AnySchemaDefinition): string | * When using this naming convention, the schema description can be omitted. */ function isSchemaOfCommonNames(schema: AnySchemaDefinition): boolean { + if (schema.appliedDirectives().length > 0) { + return false; + } for (const [root, type] of schema.roots) { if (type.name != defaultRootTypeName(root)) { return false; @@ -95,8 +77,8 @@ export function printDirectiveDefinition(directive: AnyDirectiveDefinition): str const args = directive.arguments().size == 0 ? "" : [...directive.arguments().values()].map(arg => arg.toString()).join(', '); - // TODO: missing isRepeatable and locations - return `directive @${directive}${args}`; + const locations = directive.locations.join(' | '); + return `directive @${directive}${args}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } function printAppliedDirectives(element: AnySchemaElement): string { From 7ef5482d44dd7fcae97a3fa4acddaf7d763bd95e Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Mon, 14 Jun 2021 14:50:53 +0200 Subject: [PATCH 05/22] Improve API consistency --- core-js/src/__tests__/definitions.test.ts | 39 ++- core-js/src/definitions.ts | 386 +++++++++++++--------- core-js/src/federation.ts | 28 +- core-js/src/print.ts | 14 +- 4 files changed, 274 insertions(+), 193 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 15917bd11..825bc781f 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -9,7 +9,8 @@ import { MutableType, ObjectType, Type, - BuiltIns + BuiltIns, + AnyDirectiveDefinition } from '../../dist/definitions'; import { printSchema @@ -27,7 +28,7 @@ declare global { namespace jest { interface Matchers { toHaveField(name: string, type?: AnyType): R; - toHaveDirective(name: string, args?: Map): R; + toHaveDirective(directive: AnyDirectiveDefinition, args?: Map): R; } } } @@ -47,9 +48,9 @@ expect.extend({ pass: false }; } - if (type && field.type() != type) { + if (type && field.type != type) { return { - message: () => `Expected field ${parentType}.${name} to have type ${type} but got type ${field.type()}`, + message: () => `Expected field ${parentType}.${name} to have type ${type} but got type ${field.type}`, pass: false }; } @@ -59,17 +60,17 @@ expect.extend({ } }, - toHaveDirective(element: AnySchemaElement, name: string, args?: Map) { - const directives = element.appliedDirective(name); + toHaveDirective(element: AnySchemaElement, definition: AnyDirectiveDefinition, args?: Map) { + const directives = element.appliedDirective(definition as any); if (directives.length == 0) { return { - message: () => `Cannot find directive @${name} applied to element ${element} (whose applied directives are [${element.appliedDirectives().join(', ')}]`, + message: () => `Cannot find directive @${definition} applied to element ${element} (whose applied directives are [${element.appliedDirectives.join(', ')}]`, pass: false }; } if (!args) { return { - message: () => `Expected directive @${name} to not be applied to ${element} but it is`, + message: () => `Expected directive @${definition} to not be applied to ${element} but it is`, pass: true }; } @@ -84,7 +85,7 @@ expect.extend({ } } return { - message: () => `Element ${element} has application of directive @${name} but not with the requested arguments. Got applications: [${directives.join(', ')}]`, + message: () => `Element ${element} has application of directive @${definition} but not with the requested arguments. Got applications: [${directives.join(', ')}]`, pass: false } } @@ -94,16 +95,18 @@ test('building a simple mutable schema programatically and converting to immutab const mutDoc = MutableSchema.empty(federationBuiltIns); const mutQueryType = mutDoc.schemaDefinition.setRoot('query', mutDoc.addObjectType('Query')); const mutTypeA = mutDoc.addObjectType('A'); + const inaccessible = mutDoc.directive('inaccessible')!; + const key = mutDoc.directive('key')!; mutQueryType.addField('a', mutTypeA); mutTypeA.addField('q', mutQueryType); - mutTypeA.applyDirective('inaccessible'); - mutTypeA.applyDirective('key', new Map([['fields', 'a']])); + mutTypeA.applyDirective(inaccessible); + mutTypeA.applyDirective(key, new Map([['fields', 'a']])); // Sanity check expect(mutQueryType).toHaveField('a', mutTypeA); expect(mutTypeA).toHaveField('q', mutQueryType); - expect(mutTypeA).toHaveDirective('inaccessible'); - expect(mutTypeA).toHaveDirective('key', new Map([['fields', 'a']])); + expect(mutTypeA).toHaveDirective(inaccessible); + expect(mutTypeA).toHaveDirective(key, new Map([['fields', 'a']])); const doc = mutDoc.toImmutable(); const queryType = doc.type('Query'); @@ -113,8 +116,8 @@ test('building a simple mutable schema programatically and converting to immutab expectObjectType(typeA); expect(queryType).toHaveField('a', typeA); expect(typeA).toHaveField('q', queryType); - expect(typeA).toHaveDirective('inaccessible'); - expect(typeA).toHaveDirective('key', new Map([['fields', 'a']])); + expect(typeA).toHaveDirective(inaccessible); + expect(typeA).toHaveDirective(key, new Map([['fields', 'a']])); }); function parseAndValidateTestSchema(parser: (source: string, builtIns: BuiltIns) => S): S { @@ -141,7 +144,7 @@ type MyQuery { expect(doc.schemaDefinition.root('query')).toBe(queryType); expect(queryType).toHaveField('a', typeA); const f2 = typeA.field('f2'); - expect(f2).toHaveDirective('inaccessible'); + expect(f2).toHaveDirective(doc.directive('inaccessible')!); expect(printSchema(doc)).toBe(sdl); return doc; } @@ -187,7 +190,7 @@ test('removal of all directives of a schema', () => { `, federationBuiltIns); for (const element of doc.allSchemaElement()) { - element.appliedDirectives().forEach(d => d.remove()); + element.appliedDirectives.forEach(d => d.remove()); } expect(printSchema(doc)).toBe( @@ -239,7 +242,7 @@ test('removal of all inacessible elements of a schema', () => { `, federationBuiltIns); for (const element of doc.allSchemaElement()) { - if (element.appliedDirective('inaccessible').length > 0) { + if (element.appliedDirective(doc.directive('inaccessible')!).length > 0) { element.remove(); } } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index af89106c3..aee09baeb 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -2,6 +2,7 @@ import { ASTNode, DefinitionNode, DirectiveDefinitionNode, + DirectiveLocation, DirectiveLocationEnum, DirectiveNode, DocumentNode, @@ -17,7 +18,6 @@ import { } from "graphql"; import { assert } from "./utils"; import deepEqual from 'deep-equal'; -import { DirectiveLocation } from "graphql"; export type QueryRoot = 'query'; export type MutationRoot = 'mutation'; @@ -43,6 +43,7 @@ type ImmutableWorld = { outputType: OutputType, wrapperType: WrapperType, listType: ListType, + nonNullType: NonNullType, fieldDefinition: FieldDefinition, fieldArgumentDefinition: ArgumentDefinition, inputFieldDefinition: InputFieldDefinition, @@ -69,6 +70,7 @@ type MutableWorld = { outputType: MutableOutputType, wrapperType: MutableWrapperType, listType: MutableListType, + nonNullType: MutableNonNullType, fieldDefinition: MutableFieldDefinition, fieldArgumentDefinition: MutableArgumentDefinition, inputFieldDefinition: MutableInputFieldDefinition, @@ -84,9 +86,9 @@ type World = ImmutableWorld | MutableWorld; export type Type = InputType | OutputType; export type NamedType = ScalarType | ObjectType | UnionType | InputObjectType; -export type OutputType = ScalarType | ObjectType | UnionType | ListType; -export type InputType = ScalarType | InputObjectType; -export type WrapperType = ListType; +export type OutputType = ScalarType | ObjectType | UnionType | ListType | NonNullType; +export type InputType = ScalarType | InputObjectType | ListType | NonNullType; +export type WrapperType = ListType | NonNullType; export type OutputTypeReferencer = FieldDefinition; export type InputTypeReferencer = InputFieldDefinition | ArgumentDefinition; @@ -94,9 +96,9 @@ export type ObjectTypeReferencer = OutputType | UnionType | SchemaDefinition; export type MutableType = MutableOutputType | MutableInputType; export type MutableNamedType = MutableScalarType | MutableObjectType | MutableUnionType | MutableInputObjectType; -export type MutableOutputType = MutableScalarType | MutableObjectType | MutableUnionType | MutableListType; -export type MutableInputType = MutableScalarType | MutableInputObjectType; -export type MutableWrapperType = MutableListType; +export type MutableOutputType = MutableScalarType | MutableObjectType | MutableUnionType | MutableListType | MutableNonNullType; +export type MutableInputType = MutableScalarType | MutableInputObjectType | MutableListType | MutableNonNullType; +export type MutableWrapperType = MutableListType | NonNullType; export type MutableOutputTypeReferencer = MutableFieldDefinition; export type MutableInputTypeReferencer = MutableInputFieldDefinition | MutableArgumentDefinition; @@ -105,16 +107,17 @@ export type MutableObjectTypeReferencer = MutableOutputType | MutableUnionType | // Those exists to make it a bit easier to write code that work on both mutable and immutable variants, if one so wishes. export type AnySchema = Schema | MutableSchema; export type AnySchemaElement = SchemaElement | MutableSchemaElement; -export type AnyType = AnyOutputType | AnyInputType; -export type AnyNamedType = AnyScalarType | AnyObjectType | AnyUnionType | AnyInputObjectType; -export type AnyOutputType = AnyScalarType | AnyObjectType | AnyUnionType | AnyListType; -export type AnyInputType = AnyScalarType | AnyInputObjectType; -export type AnyWrapperType = AnyListType; +export type AnyType = Type | MutableType; +export type AnyNamedType = NamedType | MutableNamedType; +export type AnyOutputType = OutputType | MutableOutputType; +export type AnyInputType = InputType | MutableInputType; +export type AnyWrapperType = WrapperType | MutableWrapperType; export type AnyScalarType = ScalarType | MutableScalarType; export type AnyObjectType = ObjectType | MutableObjectType; export type AnyUnionType = UnionType | MutableUnionType; export type AnyInputObjectType = InputObjectType | MutableInputObjectType; export type AnyListType = ListType | MutableListType; +export type AnyNonNullType = NonNullType | MutableNonNullType; export type AnySchemaDefinition = SchemaDefinition | MutableSchemaDefinition; export type AnyDirectiveDefinition = DirectiveDefinition | MutableDirectiveDefinition; @@ -131,7 +134,7 @@ export function isNamedType(type: W['type']): type is W['namedT } export function isWrapperType(type: W['type']): type is W['wrapperType'] { - return type.kind == 'ListType'; + return type.kind == 'ListType' || type.kind == 'NonNullType'; } export function isOutputType(type: W['type']): type is W['outputType'] { @@ -165,22 +168,13 @@ export interface Named { readonly name: string; } -function valueToString(v: any): string { - return JSON.stringify(v); -} - -function valueEquals(a: any, b: any): boolean { - return deepEqual(a, b); -} - -// TODO: make most of those a field since they are essentially a "property" of the element (schema() excluded maybe). export interface SchemaElement { - coordinate(): string; + coordinate: string; schema(): W['schema'] | W['detached']; - parent(): W['schemaElement'] | W['schema'] | W['detached']; - source(): ASTNode | undefined; - appliedDirectives(): readonly W['directive'][]; - appliedDirective(name: string): W['directive'][]; + parent: W['schemaElement'] | W['schema'] | W['detached']; + source: ASTNode | undefined; + appliedDirectives: readonly W['directive'][]; + appliedDirective(definition: W['directiveDefinition']): W['directive'][]; } export interface MutableSchemaElement extends SchemaElement { @@ -195,7 +189,7 @@ abstract class BaseElement

d.name == name); + appliedDirective(definition: W['directiveDefinition']): W['directive'][] { + return this._appliedDirectives.filter(d => d.name == definition.name); } protected addAppliedDirective(directive: W['directive']): W['directive'] { // TODO: should we dedup directives applications with the same arguments? - // TODO: also, should we reject directive applications for directives that are not declared (maybe do so in the Directive ctor - // and add a link to the definition)? this._appliedDirectives.push(directive); return directive; } protected removeTypeReference(_: W['namedType']): void { } + + protected checkModification(addedElement?: { schema(): W['schema'] | W['detached']}) { + // Otherwise, we can only modify attached element (allowing manipulating detached elements too much + // might our lives harder). + if (!this.schema()) { + throw new GraphQLError(`Cannot modify detached element ${this}`); + } + if (addedElement && addedElement.schema() != this.schema()) { + const attachement = addedElement.schema() ? 'attached to another schema' : 'detached'; + throw new GraphQLError(`Cannot add element ${addedElement} to ${this} as they not attached to the same schema (${addedElement} is ${attachement})`); + } + } } abstract class BaseNamedElement

extends BaseElement implements Named { @@ -262,7 +266,7 @@ abstract class BaseNamedType extends BaseNamedElement extends BaseNamedElement { } for (const directive of this.directives.values()) { yield directive; - yield* directive.arguments().values(); + yield* directive.arguments.values(); } } } @@ -454,8 +470,18 @@ export class MutableSchema extends BaseSchema { return this.addType(name, n => Ctors.mutable.createScalarType(n, this, false)); } - addDirective(directive: MutableDirectiveDefinition) { - this.directivesMap.set(directive.name, directive); + addOrGetUnionType(name: string): MutableUnionType { + return this.addOrGetType(name, 'UnionType', n => this.addUnionType(n)) as MutableUnionType; + } + + addUnionType(name: string): MutableUnionType { + return this.addType(name, n => Ctors.mutable.createUnionType(n, this, false)); + } + + addDirective(name: string): MutableDirectiveDefinition { + const directive = Ctors.mutable.createDirectiveDefinition(name, this, false); + this.directivesMap.set(name, directive); + return directive; } toImmutable(builtIns?: BuiltIns): Schema { @@ -473,7 +499,7 @@ export class SchemaDefinition extends BaseElem super(parent, source); } - coordinate(): string { + get coordinate(): string { return ''; } @@ -502,16 +528,13 @@ export class SchemaDefinition extends BaseElem export class MutableSchemaDefinition extends SchemaDefinition implements MutableSchemaElement { setRoot(rootType: SchemaRoot, objectType: MutableObjectType): MutableObjectType { - if (objectType.schema() != this.schema()) { - const attachement = objectType.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot use provided type ${objectType} for ${rootType} as it is not attached to this schema (it is ${attachement})`); - } + this.checkModification(objectType); this.rootsMap.set(rootType, objectType); return objectType; } - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } remove(): never[] { @@ -540,8 +563,8 @@ export class ScalarType extends BaseNamedType< } export class MutableScalarType extends ScalarType implements MutableSchemaElement { - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -600,7 +623,7 @@ export class ObjectType extends BaseNamedType< *allChildrenElements(): Generator { for (const field of this.fieldsMap.values()) { yield field; - yield* field.arguments().values(); + yield* field.arguments.values(); } } @@ -611,16 +634,7 @@ export class ObjectType extends BaseNamedType< export class MutableObjectType extends ObjectType implements MutableSchemaElement { addField(name: string, type: MutableType): MutableFieldDefinition { - if (this.isBuiltIn) { - throw Error(`Cannot add field to built-in type ${this.name}`); - } - if (this.field(name)) { - throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); - } - if (type.schema() != this.schema()) { - const attachement = type.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this schema (it is ${attachement})`); - } + this.checkModification(type); if (!isOutputType(type)) { throw new GraphQLError(`Cannot use type ${type} for field ${name} as it is an input type (fields can only use output types)`); } @@ -629,8 +643,8 @@ export class MutableObjectType extends ObjectType implements Mutab return field; } - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -695,20 +709,14 @@ export class UnionType extends BaseNamedType implements MutableSchemaElement { addType(type: MutableObjectType): void { - if (this.isBuiltIn) { - throw Error(`Cannot modify built-in type ${this.name}`); - } - if (type.schema() != this.schema()) { - const attachement = type.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot add provided type ${type} to union ${this} as it is not attached to this schema (it is ${attachement})`); - } + this.checkModification(type); if (!this.typesList.includes(type)) { this.typesList.push(type); } } - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -775,24 +783,18 @@ export class InputObjectType extends BaseNamed } export class MutableInputObjectType extends InputObjectType implements MutableSchemaElement { - addField(name: string, type: MutableInputType): MutableInputFieldDefinition { - if (this.isBuiltIn) { - throw Error(`Cannot modify built-in type ${this.name}`); - } - if (this.field(name)) { - throw new GraphQLError(`Field ${name} already exists in type ${this} (${this.field(name)})`); - } - if (type.schema() != this.schema()) { - const attachement = type.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot use provided type ${type} as it is not attached to this schema (it is ${attachement})`); + addField(name: string, type: MutableType): MutableInputFieldDefinition { + this.checkModification(type); + if (!isInputType(type)) { + throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an output type (input fields can only use input types)`); } const field = Ctors.mutable.createInputFieldDefinition(name, this, type); this.fieldsMap.set(name, field); return field; } - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -829,6 +831,14 @@ export class MutableInputObjectType extends InputObjectType implem } } +export function listType(type: T): ListType { + return Ctors.immutable.createList(type); +} + +export function mutableListType(type: T): MutableListType { + return Ctors.mutable.createList(type); +} + export class ListType { protected constructor(protected _type: T) {} @@ -838,7 +848,7 @@ export class ListType { return this.baseType().schema() as W['schema']; } - ofType(): T { + get ofType(): T { return this._type; } @@ -847,12 +857,47 @@ export class ListType { } toString(): string { - return `[${this.ofType()}]`; + return `[${this.ofType}]`; } } export class MutableListType extends ListType {} +export type NullableType = W['namedType'] | W['listType']; +export type MutableNullableType = NullableType; + +export function nonNullType(type: T): NonNullType { + return Ctors.immutable.createNonNull(type); +} + +export function mutableNonNullType(type: T): MutableNonNullType { + return Ctors.mutable.createNonNull(type); +} + +export class NonNullType, W extends World = ImmutableWorld> { + protected constructor(protected _type: T) {} + + kind: 'NonNullType' = 'NonNullType'; + + schema(): W['schema'] { + return this.baseType().schema() as W['schema']; + } + + get ofType(): T { + return this._type; + } + + baseType(): W['namedType'] { + return isWrapperType(this._type) ? this._type.baseType() : this._type as W['namedType']; + } + + toString(): string { + return `${this.ofType}!`; + } +} + +export class MutableNonNullType extends NonNullType {} + export class FieldDefinition extends BaseNamedElement { protected readonly _args: Map = new Map(); @@ -867,16 +912,16 @@ export class FieldDefinition extends BaseNamed kind: 'FieldDefinition' = 'FieldDefinition'; - coordinate(): string { - const parent = this.parent(); - return `${parent == undefined ? '' : parent.coordinate()}.${this.name}`; + get coordinate(): string { + const parent = this.parent; + return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } - type(): W['outputType'] | W['detached'] { + get type(): W['outputType'] | W['detached'] { return this._type; } - arguments(): ReadonlyMap { + get arguments(): ReadonlyMap { return this._args; } @@ -894,26 +939,22 @@ export class FieldDefinition extends BaseNamed const args = this._args.size == 0 ? "" : '(' + [...this._args.values()].map(arg => arg.toString()).join(', ') + ')'; - return `${this.name}${args}: ${this.type()}`; + return `${this.name}${args}: ${this.type}`; } } export class MutableFieldDefinition extends FieldDefinition implements MutableSchemaElement { - setType(type: MutableOutputType): MutableFieldDefinition { - if (!this.schema()) { - // Let's not allow manipulating detached elements too much as this could make our lives harder. - throw new GraphQLError(`Cannot set the type of field ${this.name} as it is detached`); - } - if (type.schema() != this.schema()) { - const attachement = type.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot set provided type ${type} to field ${this.name} as it is not attached to this schema (it is ${attachement})`); + setType(type: MutableType): MutableFieldDefinition { + this.checkModification(type); + if (!isOutputType(type)) { + throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an input type (fields can only use output types)`); } this._type = type; return this; } - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -949,14 +990,14 @@ export class InputFieldDefinition extends Base super(name, parent, source); } - coordinate(): string { - const parent = this.parent(); - return `${parent == undefined ? '' : parent.coordinate()}.${this.name}`; + get coordinate(): string { + const parent = this.parent; + return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } kind: 'InputFieldDefinition' = 'InputFieldDefinition'; - type(): W['inputType'] | W['detached'] { + get type(): W['inputType'] | W['detached'] { return this._type; } @@ -967,26 +1008,22 @@ export class InputFieldDefinition extends Base } toString(): string { - return `${this.name}: ${this.type()}`; + return `${this.name}: ${this.type}`; } } export class MutableInputFieldDefinition extends InputFieldDefinition implements MutableSchemaElement { - setType(type: MutableInputType): MutableInputFieldDefinition { - if (!this.schema()) { - // Let's not allow manipulating detached elements too much as this could make our lives harder. - throw new GraphQLError(`Cannot set the type of input field ${this.name} as it is detached`); - } - if (type.schema() != this.schema()) { - const attachement = type.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot set provided type ${type} to input field ${this.name} as it is not attached to this schema (it is ${attachement})`); + setType(type: MutableType): MutableInputFieldDefinition { + this.checkModification(type); + if (!isInputType(type)) { + throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an output type (input fields can only use input types)`); } this._type = type; return this; } - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -1022,12 +1059,12 @@ export class ArgumentDefinition

' : parent.coordinate()}(${this.name}:)`; + get coordinate(): string { + const parent = this.parent; + return `${parent == undefined ? '' : parent.coordinate}(${this.name}:)`; } - type(): W['inputType'] | W['detached'] { + get type(): W['inputType'] | W['detached'] { return this._type; } @@ -1048,8 +1085,8 @@ export class ArgumentDefinition

extends ArgumentDefinition implements MutableSchemaElement { - applyDirective(name: string, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(name, this, args ?? new Map())); + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } /** @@ -1062,7 +1099,7 @@ export class MutableArgumentDefinition

).delete(this.name); + (this._parent.arguments as Map).delete(this.name); // We "clean" all the attributes of the object. This is because we mean detached element to be essentially // dead and meant to be GCed and this ensure we don't prevent that for no good reason. this._parent = undefined; @@ -1089,11 +1126,11 @@ export class DirectiveDefinition extends BaseN kind: 'Directive' = 'Directive'; - coordinate(): string { + get coordinate(): string { return `@{this.name}`; } - arguments(): ReadonlyMap { + get arguments(): ReadonlyMap { return this._args; } @@ -1128,17 +1165,21 @@ export class DirectiveDefinition extends BaseN } export class MutableDirectiveDefinition extends DirectiveDefinition implements MutableSchemaElement { - addArgument(name: string, type: MutableInputType, defaultValue?: any): MutableArgumentDefinition { + protected checkModification(addedElement?: { schema(): MutableSchema | undefined}) { + // We cannot modify built-in, unless they are not attached. if (this.isBuiltIn) { - throw Error(`Cannot modify built-in directive ${this.name}`); - } - if (!this.schema()) { - // Let's not allow manipulating detached elements too much as this could make our lives harder. - throw new GraphQLError(`Cannot add argument to directive definition ${this.name} as it is detached`); + if (this.schema()) { + throw Error(`Cannot modify built-in ${this}`); + } + } else { + super.checkModification(addedElement); } - if (type.schema() != this.schema()) { - const attachement = type.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot use type ${type} for argument of directive definition ${this.name} as it is not attached to this schema (it is ${attachement})`); + } + + addArgument(name: string, type: MutableType, defaultValue?: any): MutableArgumentDefinition { + this.checkModification(type); + if (!isInputType(type)) { + throw new GraphQLError(`Cannot use type ${type} for field ${name} as it is an output type (directive definition arguments can only use input types)`); } const newArg = Ctors.mutable.createDirectiveArgumentDefinition(name, this, type, defaultValue); this._args.set(name, newArg); @@ -1211,11 +1252,11 @@ export class Directive implements Named { return this._parent?.schema(); } - parent(): W['schemaElement'] | W['detached'] { + get parent(): W['schemaElement'] | W['detached'] { return this._parent; } - definition(): W['directiveDefinition'] | W['detached'] { + get definition(): W['directiveDefinition'] | W['detached'] { const doc = this.schema(); return doc?.directive(this.name); } @@ -1258,7 +1299,7 @@ export class MutableDirective extends Directive { if (!this._parent) { return false; } - const parentDirectives = this._parent.appliedDirectives() as MutableDirective[]; + const parentDirectives = this._parent.appliedDirectives as MutableDirective[]; const index = parentDirectives.indexOf(this); assert(index >= 0, `Directive ${this} lists ${this._parent} as parent, but that parent doesn't list it as applied directive`); parentDirectives.splice(index, 1); @@ -1281,6 +1322,7 @@ class Ctors { (name, doc, builtIn, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(InputObjectType, null, name, doc, builtIn, source)), (type) => new (Function.prototype.bind.call(ListType, null, type)), + (type) => new (Function.prototype.bind.call(NonNullType, null, type)), (name, parent, type, source) => new (Function.prototype.bind.call(FieldDefinition, null, name, parent, type, source)), (name, parent, type, source) => new (Function.prototype.bind.call(InputFieldDefinition, null, name, parent, type, source)), (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), @@ -1303,6 +1345,7 @@ class Ctors { (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableInputObjectType, null, name, doc, builtIn, source)), (type) => new (Function.prototype.bind.call(MutableListType, null, type)), + (type) => new (Function.prototype.bind.call(MutableNonNullType, null, type)), (name, parent, type, source) => new (Function.prototype.bind.call(MutableFieldDefinition, null, name, parent, type, source)), (name, parent, type, source) => new (Function.prototype.bind.call(MutableInputFieldDefinition, null, name, parent, type, source)), (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), @@ -1320,6 +1363,7 @@ class Ctors { readonly createUnionType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['unionType'], readonly createInputObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['inputObjectType'], readonly createList: (type: T) => W['listType'], + readonly createNonNull: (type: T) => W['nonNullType'], readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], readonly createInputFieldDefinition: (name: string, parent: W['inputObjectType'] | W['detached'], type: W['inputType'], source?: ASTNode) => W['inputFieldDefinition'], readonly createFieldArgumentDefinition: (name: string, parent: W['fieldDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['fieldArgumentDefinition'], @@ -1387,11 +1431,17 @@ export class BuiltIns { } protected populateBuiltInDirectives(): void { - // TODO: add arguments and locations - this.addDirective('include'); - this.addDirective('skip'); - this.addDirective('deprecated'); - this.addDirective('specifiedBy'); + for (const name of ['include', 'skip']) { + this.addDirective(name) + .addLocations('FIELD', 'FRAGMENT_SPREAD', 'FRAGMENT_DEFINITION') + .addArgument('if', mutableNonNullType(this.getType('Boolean'))); + } + this.addDirective('deprecated') + .addLocations('FIELD_DEFINITION', 'ENUM_VALUE') + .addArgument('reason', this.getType('String'), 'No Longer Supported'); + this.addDirective('specifiedBy') + .addLocations('SCALAR') + .addArgument('url', mutableNonNullType(this.getType('String'))); } protected getType(name: string): MutableNamedType { @@ -1413,6 +1463,10 @@ export class BuiltIns { return this.addType(Ctors.mutable.createObjectType(name, undefined, true)); } + protected addUnionType(name: string): MutableUnionType { + return this.addType(Ctors.mutable.createUnionType(name, undefined, true)); + } + protected addDirective(name: string): MutableDirectiveDefinition { const directive = Ctors.mutable.createDirectiveDefinition(name, undefined, true); this._builtInDirectives.set(directive.name, directive); @@ -1422,6 +1476,13 @@ export class BuiltIns { export const graphQLBuiltIns = new BuiltIns(); +function valueToString(v: any): string { + return JSON.stringify(v); +} + +function valueEquals(a: any, b: any): boolean { + return deepEqual(a, b); +} function addTypeDefinition(namedType: W['namedType'], schema: W['schema']) { (schema.types as Map).set(namedType.name, namedType); @@ -1452,11 +1513,11 @@ function addRoot(root: SchemaRoot, typeName: string, schemaDefi } function addFieldArg(arg: W['fieldArgumentDefinition'], field: W['fieldDefinition']) { - (field.arguments() as Map).set(arg.name, arg); + (field.arguments as Map).set(arg.name, arg); } function addDirectiveArg(arg: W['directiveArgumentDefinition'], directive: W['directiveDefinition']) { - (directive.arguments() as Map).set(arg.name, arg); + (directive.arguments as Map).set(arg.name, arg); } function addField(field: W['fieldDefinition'] | W['inputFieldDefinition'], objectType: W['objectType'] | W['inputObjectType']) { @@ -1474,6 +1535,9 @@ function addReferencerToType(referencer: W['schemaElement'], ty case 'ListType': addReferencerToType(referencer, (type as W['listType']).baseType()); break; + case 'NonNullType': + addReferencerToType(referencer, (type as W['nonNullType']).baseType()); + break; default: BaseNamedType.prototype['addReferencer'].call(type, referencer); break; @@ -1588,7 +1652,7 @@ function buildDirective(directiveNode: DirectiveNode, element: args.set(argNode.name.value, buildValue(argNode.value)); } const directive = ctors.createDirective(directiveNode.name.value, element, args, directiveNode); - const definition = directive.definition(); + const definition = directive.definition; if (!definition) { throw new GraphQLError(`Unknown directive "@${directive.name}".`, directiveNode); } @@ -1640,7 +1704,7 @@ function buildWrapperTypeOrTypeRef(typeNode: TypeNode, schema: case 'ListType': return ctors.createList(buildWrapperTypeOrTypeRef(typeNode.type, schema, ctors)); case 'NonNullType': - throw new Error('TODO'); + return ctors.createNonNull(buildWrapperTypeOrTypeRef(typeNode.type, schema, ctors)); default: return schema.type(typeNode.name.value)!; } @@ -1679,7 +1743,7 @@ function buildDirectiveArgumentDefinition(inputNode: InputValue } function copy(source: WS['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['schema'] { - const doc = destCtors.addSchemaDefinition(destCtors.schema(destBuiltIns), source.schemaDefinition.source()); + const doc = destCtors.addSchemaDefinition(destCtors.schema(destBuiltIns), source.schemaDefinition.source); for (const type of source.types.values()) { addTypeDefinition(copyNamedTypeShallow(type, doc, destBuiltIns, destCtors), doc); } @@ -1721,7 +1785,7 @@ function copySchemaDefinitionInner(source: W } function copyAppliedDirectives(source: WS['schemaElement'], dest: WD['schemaElement'], destCtors: Ctors) { - for (const directive of source.appliedDirectives()) { + for (const directive of source.appliedDirectives) { BaseElement.prototype['addAppliedDirective'].call(dest, copyDirective(directive, dest, destCtors)); } } @@ -1732,7 +1796,7 @@ function copyDirective(source: WS['directive args.set(name, value); } const directive = destCtors.createDirective(source.name, parentDest, args, source.source); - const definition = directive.definition(); + const definition = directive.definition; if (!definition) { throw new GraphQLError(`Unknown directive "@${directive.name}" applied to ${parentDest}.`); } @@ -1743,7 +1807,7 @@ function copyDirective(source: WS['directive // Because types can refer to one another (through fields or directive applications), we first create a shallow copy of // all types, and then copy fields (see below) assuming that the type "shell" exists. function copyNamedTypeShallow(source: WS['namedType'], schema: WD['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['namedType'] { - return destCtors.createNamedType(source.kind, source.name, schema, destBuiltIns.isBuiltInType(source.name), source.source()); + return destCtors.createNamedType(source.kind, source.name, schema, destBuiltIns.isBuiltInType(source.name), source.source); } function copyNamedTypeInner(source: WS['namedType'], dest: WD['namedType'], destCtors: Ctors) { @@ -1773,10 +1837,10 @@ function copyNamedTypeInner(source: WS['name } function copyFieldDefinition(source: WS['fieldDefinition'], destParent: WD['objectType'], destCtors: Ctors): WD['fieldDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as WD['outputType']; - const copiedField = destCtors.createFieldDefinition(source.name, destParent, type, source.source()); + const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['outputType']; + const copiedField = destCtors.createFieldDefinition(source.name, destParent, type, source.source); copyAppliedDirectives(source, copiedField, destCtors); - for (const sourceArg of source.arguments().values()) { + for (const sourceArg of source.arguments.values()) { addFieldArg(copyFieldArgumentDefinition(sourceArg, copiedField, destCtors), copiedField); } addReferencerToType(copiedField, type); @@ -1784,8 +1848,8 @@ function copyFieldDefinition(source: WS['fie } function copyInputFieldDefinition(source: WS['inputFieldDefinition'], destParent: WD['inputObjectType'], destCtors: Ctors): WD['inputFieldDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as WD['inputType']; - const copied = destCtors.createInputFieldDefinition(source.name, destParent, type, source.source()); + const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['inputType']; + const copied = destCtors.createInputFieldDefinition(source.name, destParent, type, source.source); copyAppliedDirectives(source, copied, destCtors); addReferencerToType(copied, type); return copied; @@ -1798,30 +1862,32 @@ function copyWrapperTypeOrTypeRef(source: WS switch (source.kind) { case 'ListType': return destCtors.createList(copyWrapperTypeOrTypeRef((source as WS['listType']).ofType(), destParent, destCtors) as WD['type']); + case 'NonNullType': + return destCtors.createNonNull(copyWrapperTypeOrTypeRef((source as WS['nonNullType']).ofType(), destParent, destCtors) as WD['type']); default: return destParent.type((source as WS['namedType']).name)!; } } function copyFieldArgumentDefinition(source: WS['fieldArgumentDefinition'], destParent: WD['fieldDefinition'], destCtors: Ctors): WD['fieldArgumentDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as WD['inputType']; - const copied = destCtors.createFieldArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source()); + const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['inputType']; + const copied = destCtors.createFieldArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source); copyAppliedDirectives(source, copied, destCtors); addReferencerToType(copied, type); return copied; } function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['directiveDefinition'] { - const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, destBuiltIns.isBuiltInDirective(source.name), source.source()); - for (const sourceArg of source.arguments().values()) { + const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, destBuiltIns.isBuiltInDirective(source.name), source.source); + for (const sourceArg of source.arguments.values()) { addDirectiveArg(copyDirectiveArgumentDefinition(sourceArg, copiedDirective, destCtors), copiedDirective); } setDirectiveDefinitionRepeatableAndLocations(copiedDirective, source.repeatable, source.locations); return copiedDirective; } function copyDirectiveArgumentDefinition(source: WS['directiveArgumentDefinition'], destParent: WD['directiveDefinition'], destCtors: Ctors): WD['directiveArgumentDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type(), destParent.schema()!, destCtors) as InputType; - const copied = destCtors.createDirectiveArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source()); + const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as InputType; + const copied = destCtors.createDirectiveArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source); copyAppliedDirectives(source, copied, destCtors); addReferencerToType(copied, type); return copied; diff --git a/core-js/src/federation.ts b/core-js/src/federation.ts index 93a89c708..5838b7adb 100644 --- a/core-js/src/federation.ts +++ b/core-js/src/federation.ts @@ -1,21 +1,33 @@ import { BuiltIns } from "./definitions"; +// TODO: Need a way to deal with the fact that the _Entity type is built after validation. export class FederationBuiltIns extends BuiltIns { protected createBuiltInTypes(): void { super.populateBuiltInTypes(); - // TODO: add Entity, which is a union (initially empty, populated later) - //this.addUnionType('_Entity'); + this.addUnionType('_Entity'); this.addObjectType('_Service').addField('sdl', this.getType('String')); this.addScalarType('_Any'); } protected populateBuiltInDirectives(): void { - this.addDirective('key'); - this.addDirective('extends'); - this.addDirective('external'); - this.addDirective('requires'); - this.addDirective('provides'); - this.addDirective('inaccessible'); + this.addDirective('key') + .addLocations('OBJECT', 'INTERFACE') + .addArgument('fields', this.getType('String')); + + this.addDirective('extends') + .addLocations('OBJECT', 'INTERFACE'); + + this.addDirective('external') + .addLocations('OBJECT', 'FIELD_DEFINITION'); + + for (const name of ['requires', 'provides']) { + this.addDirective(name) + .addLocations('FIELD_DEFINITION') + .addArgument('fields', this.getType('String')); + } + + this.addDirective('inaccessible') + .addAllLocations(); } } diff --git a/core-js/src/print.ts b/core-js/src/print.ts index ff41a4907..ee7b6bdda 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -53,7 +53,7 @@ function printSchemaDefinition(schemaDefinition: AnySchemaDefinition): string | * When using this naming convention, the schema description can be omitted. */ function isSchemaOfCommonNames(schema: AnySchemaDefinition): boolean { - if (schema.appliedDirectives().length > 0) { + if (schema.appliedDirectives.length > 0) { return false; } for (const [root, type] of schema.roots) { @@ -74,15 +74,15 @@ export function printTypeDefinition(type: AnyNamedType): string { } export function printDirectiveDefinition(directive: AnyDirectiveDefinition): string { - const args = directive.arguments().size == 0 + const args = directive.arguments.size == 0 ? "" - : [...directive.arguments().values()].map(arg => arg.toString()).join(', '); + : [...directive.arguments.values()].map(arg => arg.toString()).join(', '); const locations = directive.locations.join(' | '); return `directive @${directive}${args}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } function printAppliedDirectives(element: AnySchemaElement): string { - const appliedDirectives = element.appliedDirectives(); + const appliedDirectives = element.appliedDirectives; return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map((d: AnyDirective) => d.toString()).join(" "); } @@ -110,10 +110,10 @@ function printFields(fields: AnyFieldDefinition[] | AnyInputFieldDefinition[]): function printField(field: AnyFieldDefinition | AnyInputFieldDefinition): string { let args = ''; - if (field.kind == 'FieldDefinition' && field.arguments().size > 0) { - args = '(' + [...field.arguments().values()].map(arg => `${arg}${printAppliedDirectives(arg)}`).join(', ') + ')'; + if (field.kind == 'FieldDefinition' && field.arguments.size > 0) { + args = '(' + [...field.arguments.values()].map(arg => `${arg}${printAppliedDirectives(arg)}`).join(', ') + ')'; } - return `${field.name}${args}: ${field.type()}`; + return `${field.name}${args}: ${field.type}`; } function printBlock(items: string[]): string { From ac2127635066b54322876f3ab7b77c3660457c02 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Tue, 15 Jun 2021 10:54:21 +0200 Subject: [PATCH 06/22] Add interface support --- core-js/src/__tests__/definitions.test.ts | 78 +++++++- core-js/src/definitions.ts | 210 +++++++++++++++++----- core-js/src/print.ts | 9 +- 3 files changed, 245 insertions(+), 52 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 825bc781f..1d66c9c22 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -10,7 +10,10 @@ import { ObjectType, Type, BuiltIns, - AnyDirectiveDefinition + AnyDirectiveDefinition, + InterfaceType, + MutableInterfaceType, + AnyInterfaceType } from '../../dist/definitions'; import { printSchema @@ -22,6 +25,12 @@ import { function expectObjectType(type: Type | MutableType | undefined): asserts type is ObjectType | MutableObjectType { expect(type).toBeDefined(); expect(type!.kind).toBe('ObjectType'); + +} + +function expectInterfaceType(type: Type | MutableType | undefined): asserts type is InterfaceType | MutableInterfaceType { + expect(type).toBeDefined(); + expect(type!.kind).toBe('InterfaceType'); } declare global { @@ -34,7 +43,7 @@ declare global { } expect.extend({ - toHaveField(parentType: AnyObjectType, name: string, type?: AnyType) { + toHaveField(parentType: AnyObjectType | AnyInterfaceType, name: string, type?: AnyType) { const field = parentType.field(name); if (!field) { return { @@ -264,3 +273,68 @@ type Query { a(id: String @bar): A }`); }); + +test('handling of interfaces', () => { + const doc = Schema.parse(` + type Query { + bestIs: [I!]! + } + + interface B { + a: Int + } + + interface I implements B { + a: Int + b: String + } + + type T1 implements B & I { + a: Int + b: String + c: Int + } + + type T2 implements B & I { + a: Int + b: String + c: String + } + `); + + const b = doc.type('B'); + const i = doc.type('I'); + const t1 = doc.type('T1'); + const t2 = doc.type('T2'); + expectInterfaceType(b); + expectInterfaceType(i); + expectObjectType(t1); + expectObjectType(t2); + + for (const t of [b, i, t1, t2]) { + expect(t).toHaveField('a', doc.intType()); + } + for (const t of [i, t1, t2]) { + expect(t).toHaveField('b', doc.stringType()); + } + expect(t1).toHaveField('c', doc.intType()); + expect(t2).toHaveField('c', doc.stringType()); + + expect(i.implementsInterface(b.name)).toBeTruthy(); + expect(t1.implementsInterface(b.name)).toBeTruthy(); + expect(t1.implementsInterface(i.name)).toBeTruthy(); + expect(t2.implementsInterface(b.name)).toBeTruthy(); + expect(t2.implementsInterface(i.name)).toBeTruthy(); + + const impls = b.allImplementations(); + for (let j = 0; j < impls.length; j++) { + console.log(`Element: ${i}: ${impls[j]} == ${[i, t1, t2][j]}?`); + expect(impls[j]).toBe([i, t2, t1][j]); + } + //expect(b.allImplementations()).toBe([i, t1, t2]); + //expect(i.allImplementations()).toBe([t1, t2]); + + //for (const itf of [b, i]) { + // expect(itf.possibleRuntimeTypes()).toBe([t1, t2]); + //} +}); diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index aee09baeb..603e5be44 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -18,6 +18,7 @@ import { } from "graphql"; import { assert } from "./utils"; import deepEqual from 'deep-equal'; +import { NamedTypeNode } from "graphql"; export type QueryRoot = 'query'; export type MutationRoot = 'mutation'; @@ -36,6 +37,7 @@ type ImmutableWorld = { type: Type, namedType: NamedType, objectType: ObjectType, + interfaceType: InterfaceType, scalarType: ScalarType, unionType: UnionType, inputObjectType: InputObjectType, @@ -44,8 +46,8 @@ type ImmutableWorld = { wrapperType: WrapperType, listType: ListType, nonNullType: NonNullType, - fieldDefinition: FieldDefinition, - fieldArgumentDefinition: ArgumentDefinition, + fieldDefinition: FieldDefinition, + fieldArgumentDefinition: ArgumentDefinition>, inputFieldDefinition: InputFieldDefinition, directiveDefinition: DirectiveDefinition, directiveArgumentDefinition: ArgumentDefinition, @@ -63,6 +65,7 @@ type MutableWorld = { type: MutableType, namedType: MutableNamedType, objectType: MutableObjectType, + interfaceType: MutableInterfaceType, scalarType: MutableScalarType, unionType: MutableUnionType, inputObjectType: MutableInputObjectType, @@ -71,8 +74,8 @@ type MutableWorld = { wrapperType: MutableWrapperType, listType: MutableListType, nonNullType: MutableNonNullType, - fieldDefinition: MutableFieldDefinition, - fieldArgumentDefinition: MutableArgumentDefinition, + fieldDefinition: MutableFieldDefinition, + fieldArgumentDefinition: MutableArgumentDefinition>, inputFieldDefinition: MutableInputFieldDefinition, directiveDefinition: MutableDirectiveDefinition directiveArgumentDefinition: MutableArgumentDefinition, @@ -85,24 +88,26 @@ type MutableWorld = { type World = ImmutableWorld | MutableWorld; export type Type = InputType | OutputType; -export type NamedType = ScalarType | ObjectType | UnionType | InputObjectType; -export type OutputType = ScalarType | ObjectType | UnionType | ListType | NonNullType; +export type NamedType = ScalarType | ObjectType | InterfaceType | UnionType | InputObjectType; +export type OutputType = ScalarType | ObjectType | InterfaceType | UnionType | ListType | NonNullType; export type InputType = ScalarType | InputObjectType | ListType | NonNullType; export type WrapperType = ListType | NonNullType; -export type OutputTypeReferencer = FieldDefinition; +export type OutputTypeReferencer = FieldDefinition; export type InputTypeReferencer = InputFieldDefinition | ArgumentDefinition; export type ObjectTypeReferencer = OutputType | UnionType | SchemaDefinition; +export type InterfaceTypeReferencer = ObjectType | InterfaceType; export type MutableType = MutableOutputType | MutableInputType; -export type MutableNamedType = MutableScalarType | MutableObjectType | MutableUnionType | MutableInputObjectType; -export type MutableOutputType = MutableScalarType | MutableObjectType | MutableUnionType | MutableListType | MutableNonNullType; +export type MutableNamedType = MutableScalarType | MutableObjectType | MutableInterfaceType | MutableUnionType | MutableInputObjectType; +export type MutableOutputType = MutableScalarType | MutableObjectType | MutableInterfaceType | MutableUnionType | MutableListType | MutableNonNullType; export type MutableInputType = MutableScalarType | MutableInputObjectType | MutableListType | MutableNonNullType; export type MutableWrapperType = MutableListType | NonNullType; -export type MutableOutputTypeReferencer = MutableFieldDefinition; +export type MutableOutputTypeReferencer = MutableFieldDefinition; export type MutableInputTypeReferencer = MutableInputFieldDefinition | MutableArgumentDefinition; export type MutableObjectTypeReferencer = MutableOutputType | MutableUnionType | MutableSchemaDefinition; +export type MutableInterfaceTypeReferencer = MutableObjectType | MutableInterfaceType; // Those exists to make it a bit easier to write code that work on both mutable and immutable variants, if one so wishes. export type AnySchema = Schema | MutableSchema; @@ -114,6 +119,7 @@ export type AnyInputType = InputType | MutableInputType; export type AnyWrapperType = WrapperType | MutableWrapperType; export type AnyScalarType = ScalarType | MutableScalarType; export type AnyObjectType = ObjectType | MutableObjectType; +export type AnyInterfaceType = InterfaceType | MutableInterfaceType; export type AnyUnionType = UnionType | MutableUnionType; export type AnyInputObjectType = InputObjectType | MutableInputObjectType; export type AnyListType = ListType | MutableListType; @@ -122,9 +128,9 @@ export type AnyNonNullType = NonNullType | MutableNonNullType; export type AnySchemaDefinition = SchemaDefinition | MutableSchemaDefinition; export type AnyDirectiveDefinition = DirectiveDefinition | MutableDirectiveDefinition; export type AnyDirective = Directive | MutableDirective; -export type AnyFieldDefinition = FieldDefinition | MutableFieldDefinition; +export type AnyFieldDefinition = FieldDefinition | MutableFieldDefinition; export type AnyInputFieldDefinition = InputFieldDefinition | MutableInputFieldDefinition; -export type AnyFieldArgumentDefinition = ArgumentDefinition | MutableArgumentDefinition; +export type AnyFieldArgumentDefinition = ArgumentDefinition> | MutableArgumentDefinition>; export type AnyDirectiveArgumentDefinition = ArgumentDefinition | MutableArgumentDefinition; export type AnyArgumentDefinition = AnyFieldDefinition | AnyDirectiveDefinition; @@ -598,7 +604,8 @@ export class MutableScalarType extends ScalarType implements Mutab } } -export class ObjectType extends BaseNamedType { +class FieldBasedType extends BaseNamedType { + protected readonly _interfaces: W['interfaceType'][] = []; protected readonly fieldsMap: Map = new Map(); protected constructor( @@ -610,7 +617,13 @@ export class ObjectType extends BaseNamedType< super(name, schema, isBuiltIn, source); } - kind: 'ObjectType' = 'ObjectType'; + get interfaces(): readonly W['interfaceType'][] { + return this._interfaces; + } + + implementsInterface(name: string): boolean { + return this._interfaces.some(i => i.name == name); + } get fields(): ReadonlyMap { return this.fieldsMap; @@ -627,26 +640,23 @@ export class ObjectType extends BaseNamedType< } } - protected removeTypeReference(_: W['namedType']): void { - assert(false, "Object types can never reference other types directly (their field does)"); + protected removeTypeReference(type: W['namedType']): void { + const index = this._interfaces.indexOf(type as W['interfaceType']); + if (index >= 0) { + this._interfaces.splice(index, 1); + } } -} -export class MutableObjectType extends ObjectType implements MutableSchemaElement { - addField(name: string, type: MutableType): MutableFieldDefinition { + protected addFieldInternal(t: T, name: string, type: MutableType): MutableFieldDefinition { this.checkModification(type); if (!isOutputType(type)) { throw new GraphQLError(`Cannot use type ${type} for field ${name} as it is an input type (fields can only use output types)`); } - const field = Ctors.mutable.createFieldDefinition(name, this, type); + const field = Ctors.mutable.createFieldDefinition(name, t, type); this.fieldsMap.set(name, field); return field; } - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); - } - /** * Removes this type definition from its parent schema. * @@ -660,27 +670,98 @@ export class MutableObjectType extends ObjectType implements Mutab * @returns an array of all the elements in the schema of this type (before the removal) that were * referening this type (and have thus now an undefined reference). */ - remove(): MutableObjectTypeReferencer[] { + protected removeInternal(t: T): R[] { if (!this._parent) { return []; } - removeTypeDefinition(this, this._parent); + removeTypeDefinition(t, this._parent); this._parent = undefined; for (const directive of this._appliedDirectives) { - directive.remove(); + (directive as MutableDirective).remove(); } for (const field of this.fieldsMap.values()) { - field.remove(); + (field as MutableFieldDefinition).remove(); } const toReturn = [... this._referencers].map(r => { BaseElement.prototype['removeTypeReference'].call(r, this); - return r as MutableObjectTypeReferencer; + return r as any; }); this._referencers.clear(); return toReturn; } } +export class ObjectType extends FieldBasedType { + kind: 'ObjectType' = 'ObjectType'; +} + +export class MutableObjectType extends ObjectType implements MutableSchemaElement { + addField(name: string, type: MutableType): MutableFieldDefinition { + return this.addFieldInternal(this, name, type); + } + + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + } + + /** + * Removes this type definition from its parent schema. + * + * After calling this method, this type will be "detached": it wil 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 + * referening this type (and have thus now an undefined reference). + */ + remove(): MutableObjectTypeReferencer[] { + return this.removeInternal(this); + } +} + +export class InterfaceType extends FieldBasedType { + kind: 'InterfaceType' = 'InterfaceType'; + + allImplementations(): readonly (W['objectType'] | W['interfaceType'])[] { + return [...this._referencers] as (W['objectType'] | W['interfaceType'])[]; + } + + possibleRuntimeTypes(): readonly W['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 W['objectType'][]; + } +} + +export class MutableInterfaceType extends InterfaceType implements MutableSchemaElement { + addField(name: string, type: MutableType): MutableFieldDefinition { + return this.addFieldInternal(this, name, type); + } + + applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { + return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + } + + /** + * Removes this type definition from its parent schema. + * + * After calling this method, this type will be "detached": it wil 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 + * referening this type (and have thus now an undefined reference). + */ + remove(): MutableInterfaceTypeReferencer[] { + return this.removeInternal(this); + } +} + export class UnionType extends BaseNamedType { protected readonly typesList: W['objectType'][] = []; @@ -898,12 +979,12 @@ export class NonNullType, W extends World = ImmutableW export class MutableNonNullType extends NonNullType {} -export class FieldDefinition extends BaseNamedElement { +export class FieldDefinition

extends BaseNamedElement { protected readonly _args: Map = new Map(); protected constructor( name: string, - parent: W['objectType'] | W['detached'], + parent: P | W['detached'], protected _type: W['outputType'] | W['detached'], source?: ASTNode ) { @@ -943,8 +1024,8 @@ export class FieldDefinition extends BaseNamed } } -export class MutableFieldDefinition extends FieldDefinition implements MutableSchemaElement { - setType(type: MutableType): MutableFieldDefinition { +export class MutableFieldDefinition

extends FieldDefinition implements MutableSchemaElement { + setType(type: MutableType): MutableFieldDefinition

{ this.checkModification(type); if (!isOutputType(type)) { throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an input type (fields can only use output types)`); @@ -967,7 +1048,7 @@ export class MutableFieldDefinition extends FieldDefinition implem if (!this._parent) { return []; } - (this._parent.fields as Map).delete(this.name); + (this._parent.fields as Map>).delete(this.name); // We "clean" all the attributes of the object. This is because we mean detached element to be essentially // dead and meant to be GCed and this ensure we don't prevent that for no good reason. this._parent = undefined; @@ -1084,7 +1165,7 @@ export class ArgumentDefinition

extends ArgumentDefinition implements MutableSchemaElement { +export class MutableArgumentDefinition

| MutableDirectiveDefinition> extends ArgumentDefinition implements MutableSchemaElement { applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); } @@ -1319,6 +1400,7 @@ class Ctors { (parent, source) => new (Function.prototype.bind.call(SchemaDefinition, null, parent, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(ScalarType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(ObjectType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(InterfaceType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(InputObjectType, null, name, doc, builtIn, source)), (type) => new (Function.prototype.bind.call(ListType, null, type)), @@ -1342,6 +1424,7 @@ class Ctors { (parent, source) => new (Function.prototype.bind.call(MutableSchemaDefinition, null, parent, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableScalarType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableObjectType, null, name, doc, builtIn, source)), + (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableInterfaceType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, builtIn, source)), (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableInputObjectType, null, name, doc, builtIn, source)), (type) => new (Function.prototype.bind.call(MutableListType, null, type)), @@ -1360,11 +1443,12 @@ class Ctors { private readonly createSchemaDefinition: (parent: W['schema'] | W['detached'], source?: ASTNode) => W['schemaDefinition'], readonly createScalarType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['scalarType'], readonly createObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['objectType'], + readonly createInterfaceType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['interfaceType'], readonly createUnionType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['unionType'], readonly createInputObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['inputObjectType'], readonly createList: (type: T) => W['listType'], readonly createNonNull: (type: T) => W['nonNullType'], - readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], + readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['interfaceType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], readonly createInputFieldDefinition: (name: string, parent: W['inputObjectType'] | W['detached'], type: W['inputType'], source?: ASTNode) => W['inputFieldDefinition'], readonly createFieldArgumentDefinition: (name: string, parent: W['fieldDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['fieldArgumentDefinition'], readonly createDirectiveArgumentDefinition: (name: string, parent: W['directiveDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['directiveArgumentDefinition'], @@ -1390,6 +1474,8 @@ class Ctors { return this.createScalarType(name, schema, isBuiltIn, source); case 'ObjectType': return this.createObjectType(name, schema, isBuiltIn, source); + case 'InterfaceType': + return this.createInterfaceType(name, schema, isBuiltIn, source); case 'UnionType': return this.createUnionType(name, schema, isBuiltIn, source); case 'InputObjectType': @@ -1520,7 +1606,7 @@ function addDirectiveArg(arg: W['directiveArgumentDefinition'], (directive.arguments as Map).set(arg.name, arg); } -function addField(field: W['fieldDefinition'] | W['inputFieldDefinition'], objectType: W['objectType'] | W['inputObjectType']) { +function addField(field: W['fieldDefinition'] | W['inputFieldDefinition'], objectType: W['objectType'] | W['interfaceType'] | W['inputObjectType']) { (objectType.fields as Map).set(field.name, field); } @@ -1530,6 +1616,13 @@ function addTypeToUnion(typeName: string, unionType: W['unionTy addReferencerToType(unionType, type); } +function addImplementedInterface(implemented: W['interfaceType'], implementer: W['interfaceType'] | W['objectType']) { + if (!implementer.implementsInterface(implemented.name)) { + (implementer.interfaces as W['interfaceType'][]).push(implemented); + addReferencerToType(implementer, implemented); + } +} + function addReferencerToType(referencer: W['schemaElement'], type: W['type']) { switch (type.kind) { case 'ListType': @@ -1632,6 +1725,14 @@ function withoutTrailingDefinition(str: string): string { return str.slice(0, str.length - 'Definition'.length); } +function getReferencedType(schema: W['schema'], node: NamedTypeNode): W['namedType'] { + const type = schema.type(node.name.value); + if (!type) { + throw new GraphQLError(`Unknown type ${node.name.value}`, node); + } + return type; +} + function buildSchemaDefinition(schemaNode: SchemaDefinitionNode, schema: W['schema'], ctors: Ctors) { ctors.addSchemaDefinition(schema, schemaNode); buildAppliedDirectives(schemaNode, schema.schemaDefinition, ctors); @@ -1664,13 +1765,20 @@ function buildNamedTypeInner(definitionNode: DefinitionNode & N buildAppliedDirectives(definitionNode, type, ctors); switch (definitionNode.kind) { case 'ObjectTypeDefinition': - const objectType = type as W['objectType']; + case 'InterfaceTypeDefinition': + const fieldBasedType = type as W['objectType'] | W['interfaceType']; for (const fieldNode of definitionNode.fields ?? []) { - addField(buildFieldDefinition(fieldNode, objectType, ctors), objectType); + addField(buildFieldDefinition(fieldNode, fieldBasedType, ctors), fieldBasedType); + } + for (const itfNode of definitionNode.interfaces ?? []) { + const itfType = getReferencedType(type.schema()!, itfNode); + if (itfType.kind != 'InterfaceType') { + // TODO: check what error graphql-js thrown for this. + throw new GraphQLError(`Type ${fieldBasedType} cannot implement non-interface type ${itfType}`, [definitionNode, itfNode]); + } + addImplementedInterface(itfType, fieldBasedType); } break; - case 'InterfaceTypeDefinition': - throw new Error("TODO"); case 'UnionTypeDefinition': const unionType = type as W['unionType']; for (const namedType of definitionNode.types ?? []) { @@ -1688,7 +1796,7 @@ function buildNamedTypeInner(definitionNode: DefinitionNode & N } } -function buildFieldDefinition(fieldNode: FieldDefinitionNode, parentType: W['objectType'], ctors: Ctors): W['fieldDefinition'] { +function buildFieldDefinition(fieldNode: FieldDefinitionNode, parentType: W['objectType'] | W['interfaceType'], ctors: Ctors): W['fieldDefinition'] { const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.schema()!, ctors) as W['outputType']; const builtField = ctors.createFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); buildAppliedDirectives(fieldNode, builtField, ctors); @@ -1814,10 +1922,14 @@ function copyNamedTypeInner(source: WS['name copyAppliedDirectives(source, dest, destCtors); switch (source.kind) { case 'ObjectType': - const sourceObjectType = source as WS['objectType']; - const destObjectType = dest as WD['objectType']; - for (const field of sourceObjectType.fields.values()) { - addField(copyFieldDefinition(field, destObjectType, destCtors), destObjectType); + case 'InterfaceType': + const sourceFieldBasedType = source as WS['objectType'] | WS['interfaceType']; + const destFieldBasedType = dest as WD['objectType'] | WD['interfaceType']; + for (const field of sourceFieldBasedType.fields.values()) { + addField(copyFieldDefinition(field, destFieldBasedType, destCtors), destFieldBasedType); + } + for (const itf of sourceFieldBasedType.interfaces) { + addImplementedInterface(destFieldBasedType.schema()?.type(itf.name)! as WD['interfaceType'], destFieldBasedType); } break; case 'UnionType': @@ -1836,7 +1948,7 @@ function copyNamedTypeInner(source: WS['name } } -function copyFieldDefinition(source: WS['fieldDefinition'], destParent: WD['objectType'], destCtors: Ctors): WD['fieldDefinition'] { +function copyFieldDefinition(source: WS['fieldDefinition'], destParent: WD['objectType'] | WD['interfaceType'], destCtors: Ctors): WD['fieldDefinition'] { const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['outputType']; const copiedField = destCtors.createFieldDefinition(source.name, destParent, type, source.source); copyAppliedDirectives(source, copiedField, destCtors); @@ -1861,9 +1973,9 @@ function copyWrapperTypeOrTypeRef(source: WS } switch (source.kind) { case 'ListType': - return destCtors.createList(copyWrapperTypeOrTypeRef((source as WS['listType']).ofType(), destParent, destCtors) as WD['type']); + return destCtors.createList(copyWrapperTypeOrTypeRef((source as WS['listType']).ofType, destParent, destCtors) as WD['type']); case 'NonNullType': - return destCtors.createNonNull(copyWrapperTypeOrTypeRef((source as WS['nonNullType']).ofType(), destParent, destCtors) as WD['type']); + return destCtors.createNonNull(copyWrapperTypeOrTypeRef((source as WS['nonNullType']).ofType, destParent, destCtors) as WD['type']); default: return destParent.type((source as WS['namedType']).name)!; } diff --git a/core-js/src/print.ts b/core-js/src/print.ts index ee7b6bdda..01e42b8d9 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -11,7 +11,8 @@ import { AnySchemaDefinition, AnySchemaElement, AnyUnionType, - defaultRootTypeName + defaultRootTypeName, + AnyInterfaceType } from "./definitions"; const indent = " "; // Could be made an option at some point @@ -68,6 +69,7 @@ export function printTypeDefinition(type: AnyNamedType): string { switch (type.kind) { case 'ScalarType': return printScalarType(type); case 'ObjectType': return printObjectType(type); + case 'InterfaceType': return printInterfaceType(type); case 'UnionType': return printUnionType(type); case 'InputObjectType': return printInputObjectType(type); } @@ -95,6 +97,11 @@ function printObjectType(type: AnyObjectType): string { return `type ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); } +function printInterfaceType(type: AnyInterfaceType): string { + // TODO: missing interfaces + return `interface ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +} + function printUnionType(type: AnyUnionType): string { const possibleTypes = type.types.length ? ' = ' + type.types.join(' | ') : ''; return `union ${type}${possibleTypes}`; From a4bc45ba24d5943d99f6967b302526318ffcc3a4 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Wed, 16 Jun 2021 16:23:21 +0200 Subject: [PATCH 07/22] Simplify API by remove mutable/immutable difference This ended up confusing the type system in a few too many places and added a fair amount of complexity/hackery. Instead, only a mutable version is preserved. --- core-js/src/__tests__/definitions.test.ts | 202 +- core-js/src/buildSchema.ts | 252 +++ core-js/src/definitions.ts | 2219 ++++++++------------- core-js/src/federation.ts | 31 +- core-js/src/print.ts | 74 +- package-lock.json | 38 +- 6 files changed, 1267 insertions(+), 1549 deletions(-) create mode 100644 core-js/src/buildSchema.ts diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 1d66c9c22..822164571 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -1,49 +1,42 @@ import { - AnySchema, - AnyObjectType, - AnySchemaElement, - AnyType, Schema, - MutableSchema, - MutableObjectType, - MutableType, ObjectType, Type, - BuiltIns, - AnyDirectiveDefinition, + DirectiveDefinition, InterfaceType, - MutableInterfaceType, - AnyInterfaceType + SchemaElement, + EnumType } from '../../dist/definitions'; -import { - printSchema -} from '../../dist/print'; -import { - federationBuiltIns -} from '../../dist/federation'; +import { printSchema } from '../../dist/print'; +import { buildSchema } from '../../dist/buildSchema'; +import { federationBuiltIns } from '../../dist/federation'; -function expectObjectType(type: Type | MutableType | undefined): asserts type is ObjectType | MutableObjectType { +function expectObjectType(type?: Type): asserts type is ObjectType { expect(type).toBeDefined(); expect(type!.kind).toBe('ObjectType'); - } -function expectInterfaceType(type: Type | MutableType | undefined): asserts type is InterfaceType | MutableInterfaceType { +function expectInterfaceType(type?: Type): asserts type is InterfaceType { expect(type).toBeDefined(); expect(type!.kind).toBe('InterfaceType'); } +function expectEnumType(type?: Type): asserts type is EnumType { + expect(type).toBeDefined(); + expect(type!.kind).toBe('EnumType'); +} + declare global { namespace jest { interface Matchers { - toHaveField(name: string, type?: AnyType): R; - toHaveDirective(directive: AnyDirectiveDefinition, args?: Map): R; + toHaveField(name: string, type?: Type): R; + toHaveDirective(directive: DirectiveDefinition, args?: Map): R; } } } expect.extend({ - toHaveField(parentType: AnyObjectType | AnyInterfaceType, name: string, type?: AnyType) { + toHaveField(parentType: ObjectType | InterfaceType, name: string, type?: Type) { const field = parentType.field(name); if (!field) { return { @@ -69,7 +62,7 @@ expect.extend({ } }, - toHaveDirective(element: AnySchemaElement, definition: AnyDirectiveDefinition, args?: Map) { + toHaveDirective(element: SchemaElement, definition: DirectiveDefinition, args?: Map) { const directives = element.appliedDirective(definition as any); if (directives.length == 0) { return { @@ -100,36 +93,27 @@ expect.extend({ } }); -test('building a simple mutable schema programatically and converting to immutable', () => { - const mutDoc = MutableSchema.empty(federationBuiltIns); - const mutQueryType = mutDoc.schemaDefinition.setRoot('query', mutDoc.addObjectType('Query')); - const mutTypeA = mutDoc.addObjectType('A'); - const inaccessible = mutDoc.directive('inaccessible')!; - const key = mutDoc.directive('key')!; - mutQueryType.addField('a', mutTypeA); - mutTypeA.addField('q', mutQueryType); - mutTypeA.applyDirective(inaccessible); - mutTypeA.applyDirective(key, new Map([['fields', 'a']])); - - // Sanity check - expect(mutQueryType).toHaveField('a', mutTypeA); - expect(mutTypeA).toHaveField('q', mutQueryType); - expect(mutTypeA).toHaveDirective(inaccessible); - expect(mutTypeA).toHaveDirective(key, new Map([['fields', 'a']])); - - const doc = mutDoc.toImmutable(); - const queryType = doc.type('Query'); - const typeA = doc.type('A'); - expect(queryType).toBe(doc.schemaDefinition.root('query')); - expectObjectType(queryType); - expectObjectType(typeA); +test('building a simple schema programatically', () => { + const schema = new Schema(federationBuiltIns); + const queryType = schema.schemaDefinition.setRoot('query', schema.addType(new ObjectType('Query'))); + const typeA = schema.addType(new ObjectType('A')); + const inaccessible = schema.directive('inaccessible')!; + const key = schema.directive('key')!; + + queryType.addField('a', typeA); + typeA.addField('q', queryType); + typeA.applyDirective(inaccessible); + typeA.applyDirective(key, new Map([['fields', 'a']])); + + expect(queryType).toBe(schema.schemaDefinition.root('query')); expect(queryType).toHaveField('a', typeA); expect(typeA).toHaveField('q', queryType); expect(typeA).toHaveDirective(inaccessible); expect(typeA).toHaveDirective(key, new Map([['fields', 'a']])); }); -function parseAndValidateTestSchema(parser: (source: string, builtIns: BuiltIns) => S): S { + +test('parse schema and modify', () => { const sdl = `schema { query: MyQuery @@ -144,36 +128,25 @@ type MyQuery { a: A b: Int }`; - const doc = parser(sdl, federationBuiltIns); + const schema = buildSchema(sdl, federationBuiltIns); - const queryType = doc.type('MyQuery')!; - const typeA = doc.type('A')!; + const queryType = schema.type('MyQuery')!; + const typeA = schema.type('A')!; expectObjectType(queryType); expectObjectType(typeA); - expect(doc.schemaDefinition.root('query')).toBe(queryType); + expect(schema.schemaDefinition.root('query')).toBe(queryType); expect(queryType).toHaveField('a', typeA); const f2 = typeA.field('f2'); - expect(f2).toHaveDirective(doc.directive('inaccessible')!); - expect(printSchema(doc)).toBe(sdl); - return doc; -} - + expect(f2).toHaveDirective(schema.directive('inaccessible')!); + expect(printSchema(schema)).toBe(sdl); -test('parse immutable schema', () => { - parseAndValidateTestSchema(Schema.parse); -}); - -test('parse mutable schema and modify', () => { - const doc = parseAndValidateTestSchema(MutableSchema.parse); - const typeA = doc.type('A'); - expectObjectType(typeA); expect(typeA).toHaveField('f1'); typeA.field('f1')!.remove(); expect(typeA).not.toHaveField('f1'); }); test('removal of all directives of a schema', () => { - const doc = MutableSchema.parse(` + const schema = buildSchema(` schema @foo { query: Query } @@ -198,11 +171,11 @@ test('removal of all directives of a schema', () => { directive @bar on ARGUMENT_DEFINITION `, federationBuiltIns); - for (const element of doc.allSchemaElement()) { + for (const element of schema.allSchemaElement()) { element.appliedDirectives.forEach(d => d.remove()); } - expect(printSchema(doc)).toBe( + expect(printSchema(schema)).toBe( `directive @foo on SCHEMA | FIELD_DEFINITION directive @foobar on UNION @@ -226,7 +199,7 @@ union U = A | B`); }); test('removal of all inacessible elements of a schema', () => { - const doc = MutableSchema.parse(` + const schema = buildSchema(` schema @foo { query: Query } @@ -250,13 +223,13 @@ test('removal of all inacessible elements of a schema', () => { directive @bar on ARGUMENT_DEFINITION `, federationBuiltIns); - for (const element of doc.allSchemaElement()) { - if (element.appliedDirective(doc.directive('inaccessible')!).length > 0) { + for (const element of schema.allSchemaElement()) { + if (element.appliedDirective(schema.directive('inaccessible')!).length > 0) { element.remove(); } } - expect(printSchema(doc)).toBe( + expect(printSchema(schema)).toBe( `schema @foo { query: Query } @@ -275,7 +248,7 @@ type Query { }); test('handling of interfaces', () => { - const doc = Schema.parse(` + const schema = buildSchema(` type Query { bestIs: [I!]! } @@ -302,23 +275,23 @@ test('handling of interfaces', () => { } `); - const b = doc.type('B'); - const i = doc.type('I'); - const t1 = doc.type('T1'); - const t2 = doc.type('T2'); + const b = schema.type('B'); + const i = schema.type('I'); + const t1 = schema.type('T1'); + const t2 = schema.type('T2'); expectInterfaceType(b); expectInterfaceType(i); expectObjectType(t1); expectObjectType(t2); for (const t of [b, i, t1, t2]) { - expect(t).toHaveField('a', doc.intType()); + expect(t).toHaveField('a', schema.intType()); } for (const t of [i, t1, t2]) { - expect(t).toHaveField('b', doc.stringType()); + expect(t).toHaveField('b', schema.stringType()); } - expect(t1).toHaveField('c', doc.intType()); - expect(t2).toHaveField('c', doc.stringType()); + expect(t1).toHaveField('c', schema.intType()); + expect(t2).toHaveField('c', schema.stringType()); expect(i.implementsInterface(b.name)).toBeTruthy(); expect(t1.implementsInterface(b.name)).toBeTruthy(); @@ -326,15 +299,64 @@ test('handling of interfaces', () => { expect(t2.implementsInterface(b.name)).toBeTruthy(); expect(t2.implementsInterface(i.name)).toBeTruthy(); - const impls = b.allImplementations(); - for (let j = 0; j < impls.length; j++) { - console.log(`Element: ${i}: ${impls[j]} == ${[i, t1, t2][j]}?`); - expect(impls[j]).toBe([i, t2, t1][j]); + expect(b.allImplementations()).toEqual([i, t1, t2]); + expect(i.allImplementations()).toEqual([t1, t2]); + + for (const itf of [b, i]) { + expect(itf.possibleRuntimeTypes()).toEqual([t1, t2]); } - //expect(b.allImplementations()).toBe([i, t1, t2]); - //expect(i.allImplementations()).toBe([t1, t2]); - //for (const itf of [b, i]) { - // expect(itf.possibleRuntimeTypes()).toBe([t1, t2]); - //} + b.remove(); + + expect(printSchema(schema)).toBe( +`interface I { + a: Int + b: String +} + +type Query { + bestIs: [I!]! +} + +type T1 implements I { + a: Int + b: String + c: Int +} + +type T2 implements I { + a: Int + b: String + c: String +}`); +}); + +test('handling of enums', () => { + const schema = buildSchema(` + type Query { + a: A + } + + enum E { + V1 + V2 + } + + type A { + a: Int + e: E + } + `); + + const a = schema.type('A'); + const e = schema.type('E'); + expectObjectType(a); + expectEnumType(e); + + expect(a).toHaveField('e', e); + const v1 = e.value('V1'); + const v2 = e.value('V2'); + expect(v1).toBeDefined(); + expect(v2).toBeDefined(); + expect(e.values).toEqual([v1, v2]); }); diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts new file mode 100644 index 000000000..5ab6b45f8 --- /dev/null +++ b/core-js/src/buildSchema.ts @@ -0,0 +1,252 @@ +import { + DefinitionNode, + DirectiveDefinitionNode, + DirectiveLocationEnum, + DirectiveNode, + DocumentNode, + FieldDefinitionNode, + GraphQLError, + InputValueDefinitionNode, + parse, + SchemaDefinitionNode, + Source, + TypeNode, + valueFromASTUntyped, + ValueNode, + NamedTypeNode, + ArgumentNode +} from "graphql"; +import { + BuiltIns, + Schema, + graphQLBuiltIns, + newNamedType, + NamedTypeKind, + NamedType, + SchemaDefinition, + SchemaElement, + ObjectType, + InterfaceType, + FieldDefinition, + Type, + ListType, + OutputType, + isOutputType, + isInputType, + InputType, + NonNullType, + ArgumentDefinition, + InputFieldDefinition, + DirectiveDefinition, + UnionType, + InputObjectType, + EnumType +} from "./definitions"; + +function buildValue(value?: ValueNode): any { + // TODO: Should we rewrite a version of valueFromAST instead of using valueFromASTUntyped? Afaict, what we're missing out on is + // 1) coercions, which concretely, means: + // - for enums, we get strings + // - for int, we don't get the validation that it should be a 32bit value. + // - for ID, which accepts strings and int, we don't get int converted to string. + // - for floats, we get either int or float, we don't get int converted to float. + // - we don't get any custom coercion (but neither is buildSchema in graphQL-js anyway). + // 2) type validation. + return value ? valueFromASTUntyped(value) : undefined; +} + +export function buildSchema(source: string | Source, builtIns: BuiltIns = graphQLBuiltIns): Schema { + return buildSchemaFromAST(parse(source), builtIns); +} + +export function buildSchemaFromAST(documentNode: DocumentNode, builtIns: BuiltIns = graphQLBuiltIns): Schema { + const schema = new Schema(builtIns); + // We do a first path to add all empty types and directives definition. This ensure any reference on one of + // those can be resolved in the 2nd pass, regardless of the order of the definitions in the AST. + buildNamedTypeAndDirectivesShallow(documentNode, schema); + for (const definitionNode of documentNode.definitions) { + switch (definitionNode.kind) { + case 'OperationDefinition': + case 'FragmentDefinition': + throw new GraphQLError("Invalid executable definition found while building schema", definitionNode); + case 'SchemaDefinition': + buildSchemaDefinitionInner(definitionNode, schema.schemaDefinition); + break; + case 'ScalarTypeDefinition': + case 'ObjectTypeDefinition': + case 'InterfaceTypeDefinition': + case 'UnionTypeDefinition': + case 'EnumTypeDefinition': + case 'InputObjectTypeDefinition': + buildNamedTypeInner(definitionNode, schema.type(definitionNode.name.value)!); + break; + case 'DirectiveDefinition': + buildDirectiveDefinitionInner(definitionNode, schema.directive(definitionNode.name.value)!); + break; + case 'SchemaExtension': + case 'ScalarTypeExtension': + case 'ObjectTypeExtension': + case 'InterfaceTypeExtension': + case 'UnionTypeExtension': + case 'EnumTypeExtension': + case 'InputObjectTypeExtension': + throw new Error("Extensions are a TODO"); + } + } + return schema; +} + +function buildNamedTypeAndDirectivesShallow(documentNode: DocumentNode, schema: Schema) { + for (const definitionNode of documentNode.definitions) { + switch (definitionNode.kind) { + case 'ScalarTypeDefinition': + case 'ObjectTypeDefinition': + case 'InterfaceTypeDefinition': + case 'UnionTypeDefinition': + case 'EnumTypeDefinition': + case 'InputObjectTypeDefinition': + schema.addType(newNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value)); + break; + case 'DirectiveDefinition': + schema.addDirectiveDefinition(definitionNode.name.value); + break; + } + } +} + +type NodeWithDirectives = {directives?: ReadonlyArray}; +type NodeWithArguments = {arguments?: ReadonlyArray}; + +function withoutTrailingDefinition(str: string): NamedTypeKind { + return str.slice(0, str.length - 'Definition'.length) as NamedTypeKind; +} + +function getReferencedType(node: NamedTypeNode, schema: Schema): NamedType { + const type = schema.type(node.name.value); + if (!type) { + throw new GraphQLError(`Unknown type ${node.name.value}`, node); + } + return type; +} + +function buildSchemaDefinitionInner(schemaNode: SchemaDefinitionNode, schemaDefinition: SchemaDefinition) { + schemaDefinition.source = schemaNode; + buildAppliedDirectives(schemaNode, schemaDefinition); + for (const opTypeNode of schemaNode.operationTypes) { + schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value, opTypeNode); + } +} + +function buildAppliedDirectives(elementNode: NodeWithDirectives, element: SchemaElement) { + for (const directive of elementNode.directives ?? []) { + element.applyDirective(directive.name.value, buildArgs(directive), directive) + } +} + +function buildArgs(argumentsNode: NodeWithArguments): Map { + const args = new Map(); + for (const argNode of argumentsNode.arguments ?? []) { + args.set(argNode.name.value, buildValue(argNode.value)); + } + return args; +} + +function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives, type: NamedType) { + switch (definitionNode.kind) { + case 'ObjectTypeDefinition': + case 'InterfaceTypeDefinition': + const fieldBasedType = type as ObjectType | InterfaceType; + for (const fieldNode of definitionNode.fields ?? []) { + buildFieldDefinitionInner(fieldNode, fieldBasedType.addField(fieldNode.name.value)); + } + for (const itfNode of definitionNode.interfaces ?? []) { + fieldBasedType.addImplementedInterface(itfNode.name.value, itfNode); + } + break; + case 'UnionTypeDefinition': + const unionType = type as UnionType; + for (const namedType of definitionNode.types ?? []) { + unionType.addType(namedType.name.value, namedType); + } + break; + case 'EnumTypeDefinition': + const enumType = type as EnumType; + for (const enumVal of definitionNode.values ?? []) { + enumType.addValue(enumVal.name.value); + } + break; + case 'InputObjectTypeDefinition': + const inputObjectType = type as InputObjectType; + for (const fieldNode of definitionNode.fields ?? []) { + buildInputFieldDefinitionInner(fieldNode, inputObjectType.addField(fieldNode.name.value)); + } + break; + } + buildAppliedDirectives(definitionNode, type); + type.source = definitionNode; +} + +function buildFieldDefinitionInner(fieldNode: FieldDefinitionNode, field: FieldDefinition) { + const type = buildWrapperTypeOrTypeRef(fieldNode.type, field.schema()!); + field.type = ensureOutputType(type, fieldNode.type); + for (const inputValueDef of fieldNode.arguments ?? []) { + buildArgumentDefinitionInner(inputValueDef, field.addArgument(inputValueDef.name.value)); + } + buildAppliedDirectives(fieldNode, field); + field.source = fieldNode; +} + +export function ensureOutputType(type: Type, node: TypeNode): OutputType { + if (isOutputType(type)) { + return type; + } else { + throw new GraphQLError(`Expected ${type} to be an output type`, node); + } +} + +export function ensureInputType(type: Type, node: TypeNode): InputType { + if (isInputType(type)) { + return type; + } else { + throw new GraphQLError(`Expected ${type} to be an input type`, node); + } +} + +function buildWrapperTypeOrTypeRef(typeNode: TypeNode, schema: Schema): Type { + switch (typeNode.kind) { + case 'ListType': + return new ListType(buildWrapperTypeOrTypeRef(typeNode.type, schema)); + case 'NonNullType': + const wrapped = buildWrapperTypeOrTypeRef(typeNode.type, schema); + if (wrapped.kind == 'NonNullType') { + throw new GraphQLError(`Cannot apply the non-null operator (!) twice to the same type`, typeNode); + } + return new NonNullType(wrapped); + default: + return getReferencedType(typeNode, schema); + } +} + +function buildArgumentDefinitionInner(inputNode: InputValueDefinitionNode, arg: ArgumentDefinition) { + const type = buildWrapperTypeOrTypeRef(inputNode.type, arg.schema()!); + arg.type = ensureInputType(type, inputNode.type); + buildAppliedDirectives(inputNode, arg); + arg.source = inputNode; +} + +function buildInputFieldDefinitionInner(fieldNode: InputValueDefinitionNode, field: InputFieldDefinition) { + const type = buildWrapperTypeOrTypeRef(fieldNode.type, field.schema()!); + field.type = ensureInputType(type, fieldNode.type); + buildAppliedDirectives(fieldNode, field); + field.source = fieldNode; +} + +function buildDirectiveDefinitionInner(directiveNode: DirectiveDefinitionNode, directive: DirectiveDefinition) { + for (const inputValueDef of directiveNode.arguments ?? []) { + buildArgumentDefinitionInner(inputValueDef, directive.addArgument(inputValueDef.name.value)); + } + directive.repeatable = directiveNode.repeatable; + const locations = directiveNode.locations.map(({ value }) => value as DirectiveLocationEnum); + directive.addLocations(...locations); + directive.source = directiveNode; +} diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 603e5be44..c2c1eea5d 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -1,24 +1,11 @@ import { ASTNode, - DefinitionNode, - DirectiveDefinitionNode, DirectiveLocation, DirectiveLocationEnum, - DirectiveNode, - DocumentNode, - FieldDefinitionNode, - GraphQLError, - InputValueDefinitionNode, - parse, - SchemaDefinitionNode, - Source, - TypeNode, - valueFromASTUntyped, - ValueNode + GraphQLError } from "graphql"; import { assert } from "./utils"; import deepEqual from 'deep-equal'; -import { NamedTypeNode } from "graphql"; export type QueryRoot = 'query'; export type MutationRoot = 'mutation'; @@ -29,121 +16,30 @@ export function defaultRootTypeName(root: SchemaRoot) { return root.charAt(0).toUpperCase() + root.slice(1); } -type ImmutableWorld = { - detached: never, - schema: Schema, - schemaDefinition: SchemaDefinition, - schemaElement: SchemaElement, - type: Type, - namedType: NamedType, - objectType: ObjectType, - interfaceType: InterfaceType, - scalarType: ScalarType, - unionType: UnionType, - inputObjectType: InputObjectType, - inputType: InputType, - outputType: OutputType, - wrapperType: WrapperType, - listType: ListType, - nonNullType: NonNullType, - fieldDefinition: FieldDefinition, - fieldArgumentDefinition: ArgumentDefinition>, - inputFieldDefinition: InputFieldDefinition, - directiveDefinition: DirectiveDefinition, - directiveArgumentDefinition: ArgumentDefinition, - directive: Directive, - outputTypeReferencer: OutputTypeReferencer, - inputTypeReferencer: InputTypeReferencer, - objectTypeReferencer: ObjectTypeReferencer, -} - -type MutableWorld = { - detached: undefined, - schema: MutableSchema, - schemaDefinition: MutableSchemaDefinition, - schemaElement: MutableSchemaElement, - type: MutableType, - namedType: MutableNamedType, - objectType: MutableObjectType, - interfaceType: MutableInterfaceType, - scalarType: MutableScalarType, - unionType: MutableUnionType, - inputObjectType: MutableInputObjectType, - inputType: MutableInputType, - outputType: MutableOutputType, - wrapperType: MutableWrapperType, - listType: MutableListType, - nonNullType: MutableNonNullType, - fieldDefinition: MutableFieldDefinition, - fieldArgumentDefinition: MutableArgumentDefinition>, - inputFieldDefinition: MutableInputFieldDefinition, - directiveDefinition: MutableDirectiveDefinition - directiveArgumentDefinition: MutableArgumentDefinition, - directive: MutableDirective, - inputTypeReferencer: MutableInputTypeReferencer, - outputTypeReferencer: MutableOutputTypeReferencer, - objectTypeReferencer: MutableObjectTypeReferencer, -} - -type World = ImmutableWorld | MutableWorld; - export type Type = InputType | OutputType; -export type NamedType = ScalarType | ObjectType | InterfaceType | UnionType | InputObjectType; -export type OutputType = ScalarType | ObjectType | InterfaceType | UnionType | ListType | NonNullType; -export type InputType = ScalarType | InputObjectType | ListType | NonNullType; +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 OutputTypeReferencer = FieldDefinition; export type InputTypeReferencer = InputFieldDefinition | ArgumentDefinition; -export type ObjectTypeReferencer = OutputType | UnionType | SchemaDefinition; -export type InterfaceTypeReferencer = ObjectType | InterfaceType; - -export type MutableType = MutableOutputType | MutableInputType; -export type MutableNamedType = MutableScalarType | MutableObjectType | MutableInterfaceType | MutableUnionType | MutableInputObjectType; -export type MutableOutputType = MutableScalarType | MutableObjectType | MutableInterfaceType | MutableUnionType | MutableListType | MutableNonNullType; -export type MutableInputType = MutableScalarType | MutableInputObjectType | MutableListType | MutableNonNullType; -export type MutableWrapperType = MutableListType | NonNullType; - -export type MutableOutputTypeReferencer = MutableFieldDefinition; -export type MutableInputTypeReferencer = MutableInputFieldDefinition | MutableArgumentDefinition; -export type MutableObjectTypeReferencer = MutableOutputType | MutableUnionType | MutableSchemaDefinition; -export type MutableInterfaceTypeReferencer = MutableObjectType | MutableInterfaceType; - -// Those exists to make it a bit easier to write code that work on both mutable and immutable variants, if one so wishes. -export type AnySchema = Schema | MutableSchema; -export type AnySchemaElement = SchemaElement | MutableSchemaElement; -export type AnyType = Type | MutableType; -export type AnyNamedType = NamedType | MutableNamedType; -export type AnyOutputType = OutputType | MutableOutputType; -export type AnyInputType = InputType | MutableInputType; -export type AnyWrapperType = WrapperType | MutableWrapperType; -export type AnyScalarType = ScalarType | MutableScalarType; -export type AnyObjectType = ObjectType | MutableObjectType; -export type AnyInterfaceType = InterfaceType | MutableInterfaceType; -export type AnyUnionType = UnionType | MutableUnionType; -export type AnyInputObjectType = InputObjectType | MutableInputObjectType; -export type AnyListType = ListType | MutableListType; -export type AnyNonNullType = NonNullType | MutableNonNullType; - -export type AnySchemaDefinition = SchemaDefinition | MutableSchemaDefinition; -export type AnyDirectiveDefinition = DirectiveDefinition | MutableDirectiveDefinition; -export type AnyDirective = Directive | MutableDirective; -export type AnyFieldDefinition = FieldDefinition | MutableFieldDefinition; -export type AnyInputFieldDefinition = InputFieldDefinition | MutableInputFieldDefinition; -export type AnyFieldArgumentDefinition = ArgumentDefinition> | MutableArgumentDefinition>; -export type AnyDirectiveArgumentDefinition = ArgumentDefinition | MutableArgumentDefinition; -export type AnyArgumentDefinition = AnyFieldDefinition | AnyDirectiveDefinition; - - -export function isNamedType(type: W['type']): type is W['namedType'] { +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: W['type']): type is W['wrapperType'] { +export function isWrapperType(type: Type): type is WrapperType { return type.kind == 'ListType' || type.kind == 'NonNullType'; } -export function isOutputType(type: W['type']): type is W['outputType'] { +export function isOutputType(type: Type): type is OutputType { if (isWrapperType(type)) { return isOutputType(type.baseType()); } @@ -151,18 +47,29 @@ export function isOutputType(type: W['type']): type is W['outpu case 'ScalarType': case 'ObjectType': case 'UnionType': + case 'EnumType': + case 'InterfaceType': return true; default: return false; } } -export function isInputType(type: W['type']): type is W['inputType'] { +export function ensureOutputType(type: Type): OutputType { + if (isOutputType(type)) { + return type; + } else { + throw new Error(`Type ${type} (${type.kind}) is not an output type`); + } +} + +export function isInputType(type: Type): type is InputType { if (isWrapperType(type)) { return isInputType(type.baseType()); } switch (type.kind) { case 'ScalarType': + case 'EnumType': case 'InputObjectType': return true; default: @@ -170,387 +77,495 @@ export function isInputType(type: W['type']): type is W['inputT } } -export interface Named { - readonly name: string; -} - -export interface SchemaElement { - coordinate: string; - schema(): W['schema'] | W['detached']; - parent: W['schemaElement'] | W['schema'] | W['detached']; - source: ASTNode | undefined; - appliedDirectives: readonly W['directive'][]; - appliedDirective(definition: W['directiveDefinition']): W['directive'][]; +export function ensureInputType(type: Type): InputType { + if (isInputType(type)) { + return type; + } else { + throw new Error(`Type ${type} (${type.kind}) is not an input type`); + } } -export interface MutableSchemaElement extends SchemaElement { - remove(): R[]; +export interface Named { + readonly name: string; } -abstract class BaseElement

implements SchemaElement { - protected readonly _appliedDirectives: W['directive'][] = []; - - constructor( - protected _parent: P | W['detached'], - protected _source?: ASTNode - ) {} +export abstract class SchemaElement | Schema, Referencer> { + protected _parent?: Parent; + protected readonly _appliedDirectives: Directive[] = []; + source?: ASTNode; abstract coordinate: string; - schema(): W['schema'] | W['detached'] { - if (this._parent == undefined) { + schema(): Schema | undefined { + if (!this._parent) { return undefined; - } else if ('kind' in this._parent && this._parent.kind == 'Schema') { - return this._parent as W['schema']; + } else if ( this._parent instanceof Schema) { + return this._parent as any; } else { - return (this._parent as W['schemaElement']).schema(); + return (this._parent as SchemaElement).schema(); } } - get parent(): P | W['detached'] { + get parent(): Parent | undefined { return this._parent; } - setParent(parent: P) { + protected setParent(parent: Parent) { assert(!this._parent, "Cannot set parent of a non-detached element"); this._parent = parent; } - get source(): ASTNode | undefined { - return this._source; - } - - get appliedDirectives(): readonly W['directive'][] { + get appliedDirectives(): readonly Directive[] { return this._appliedDirectives; } - appliedDirective(definition: W['directiveDefinition']): W['directive'][] { + appliedDirective(definition: DirectiveDefinition): Directive[] { return this._appliedDirectives.filter(d => d.name == definition.name); } - protected addAppliedDirective(directive: W['directive']): W['directive'] { - // TODO: should we dedup directives applications with the same arguments? - this._appliedDirectives.push(directive); - return directive; + applyDirective(directive: Directive): Directive; + applyDirective(definition: DirectiveDefinition, args?: Map): Directive; + applyDirective(name: string, args?: Map, source?: ASTNode): Directive; + applyDirective(nameOrDefOrDirective: Directive | DirectiveDefinition | string, args?: Map, source?: ASTNode): Directive { + let toAdd: Directive; + if (nameOrDefOrDirective instanceof Directive) { + this.checkUpdate(nameOrDefOrDirective); + toAdd = nameOrDefOrDirective; + } else { + let name: string; + if (typeof nameOrDefOrDirective === 'string') { + this.checkUpdate(); + const def = this.schema()!.directive(nameOrDefOrDirective); + if (!def) { + throw new GraphQLError(`Cannot apply unkown directive ${nameOrDefOrDirective}`, source); + } + name = nameOrDefOrDirective; + } else { + this.checkUpdate(nameOrDefOrDirective); + name = nameOrDefOrDirective.name; + } + toAdd = new Directive(name, args ?? new Map()); + Directive.prototype['setParent'].call(toAdd, this); + toAdd.source = source; + } + this._appliedDirectives.push(toAdd); + DirectiveDefinition.prototype['addReferencer'].call(toAdd.definition!, toAdd); + return toAdd; + } + + 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 impemented 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 removeTypeReference(_: W['namedType']): void { + protected abstract removeTypeReference(type: NamedType): void; + + protected checkRemoval() { + if (this.isElementBuiltIn() && !Schema.prototype['canModifyBuiltIn'].call(this.schema()!)) { + throw buildError(`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 checkModification(addedElement?: { schema(): W['schema'] | W['detached']}) { - // Otherwise, we can only modify attached element (allowing manipulating detached elements too much - // might our lives harder). + protected checkUpdate(addedElement?: { schema(): Schema | undefined }) { + if (this.isElementBuiltIn() && !Schema.prototype['canModifyBuiltIn'].call(this.schema()!)) { + throw buildError(`Cannot modify built-in ${this}`); + } + // 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 hierachy 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. if (!this.schema()) { - throw new GraphQLError(`Cannot modify detached element ${this}`); + throw buildError(`Cannot modify detached element ${this}`); } - if (addedElement && addedElement.schema() != this.schema()) { - const attachement = addedElement.schema() ? 'attached to another schema' : 'detached'; - throw new GraphQLError(`Cannot add element ${addedElement} to ${this} as they not attached to the same schema (${addedElement} is ${attachement})`); + if (addedElement) { + const thatSchema = addedElement.schema(); + if (thatSchema && thatSchema != this.schema()) { + throw buildError(`Cannot add element ${addedElement} to ${this} as its is attached another schema`); + } } } + + abstract remove(): Referencer[]; } -abstract class BaseNamedElement

extends BaseElement implements Named { - constructor( - readonly name: string, - parent: P | W['detached'], - source?: ASTNode - ) { - super(parent, source); +abstract class BaseNamedElement

| Schema, Referencer> extends SchemaElement implements Named { + constructor(readonly name: string) { + super(); } } -abstract class BaseNamedType extends BaseNamedElement { - protected readonly _referencers: Set = new Set(); +abstract class BaseNamedType extends BaseNamedElement { + protected readonly _referencers: Set = new Set(); - protected constructor( - name: string, - schema: W['schema'] | W['detached'], - readonly isBuiltIn: boolean, - source?: ASTNode - ) { - super(name, schema, source); + constructor(name: string, readonly isBuiltIn: boolean = false) { + super(name); + } + + private addReferencer(referencer: Referencer) { + this._referencers.add(referencer); + } + + private removeReferencer(referencer: Referencer) { + this._referencers.delete(referencer); } get coordinate(): string { return this.name; } - *allChildrenElements(): Generator { + *allChildrenElements(): Generator, void, undefined> { // Overriden by those types that do have chidrens } - private addReferencer(referencer: W['schemaElement']) { - assert(referencer, 'Referencer should exists'); - this._referencers.add(referencer); + protected isElementBuiltIn(): boolean { + return this.isBuiltIn; } - protected addAppliedDirective(directive: W['directive']): W['directive'] { - if (this.isBuiltIn) { - throw Error(`Cannot apply directive to built-in type ${this.name}`); + /** + * Removes this type definition from its parent schema. + * + * After calling this method, this type will be "detached": it wil 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 + * referening this type (and have thus now an undefined reference). + */ + remove(): Referencer[] { + if (!this._parent) { + return []; } - // TODO: we should check the directive arguments match the definition (both in names and types) - return super.addAppliedDirective(directive); - } - - protected checkModification(addedElement?: { schema(): W['schema'] | W['detached']}) { - // We cannot modify built-in, unless they are not attached. - if (this.isBuiltIn) { - if (this.schema()) { - throw Error(`Cannot modify built-in ${this}`); - } - } else { - super.checkModification(addedElement); + Schema.prototype['removeTypeInternal'].call(this._parent, this); + this._parent = undefined; + for (const directive of this._appliedDirectives) { + directive.remove(); } + this.source = undefined; + this.removeInnerElements(); + const toReturn = [... this._referencers].map(r => { + SchemaElement.prototype['removeTypeReferenceInternal'].call(r, this); + return r; + }); + this._referencers.clear(); + return toReturn; } + protected abstract removeInnerElements(): void; + toString(): string { return this.name; } } -class BaseSchema { - private _schemaDefinition: W['schemaDefinition'] | undefined = undefined; - protected readonly builtInTypes: Map = new Map(); - protected readonly typesMap: Map = new Map(); - protected readonly builtInDirectives: Map = new Map(); - protected readonly directivesMap: Map = new Map(); - - protected constructor(readonly builtIns: BuiltIns, ctors: Ctors) { - const thisSchema = this as any; - // BuiltIn types can depend on each other, so we still want to do the 2-phase copy. - for (const builtInType of builtIns.builtInTypes()) { - const type = copyNamedTypeShallow(builtInType, thisSchema, builtIns, ctors); - this.builtInTypes.set(type.name, type); +abstract class BaseNamedElementWithType | Schema, Referencer> extends BaseNamedElement { + private _type?: T; + + get type(): T | undefined { + return this._type; + } + + set type(type: T | undefined) { + if (type) { + this.checkUpdate(type); + } else { + this.checkRemoval(); } - for (const builtInType of builtIns.builtInTypes()) { - copyNamedTypeInner(builtInType, this.type(builtInType.name)!, ctors); + if (this._type) { + removeReferenceToType(this, this._type); } - for (const builtInDirective of builtIns.builtInDirectives()) { - const directive = copyDirectiveDefinition(builtInDirective, thisSchema, builtIns, ctors); - this.builtInDirectives.set(directive.name, directive); + this._type = type; + if (type) { + addReferenceToType(this, type); } } - kind: 'Schema' = 'Schema'; - - // Used only through cheating the type system. - private setSchemaDefinition(schemaDefinition: W['schemaDefinition']) { - this._schemaDefinition = schemaDefinition; + protected removeTypeReference(type: NamedType) { + if (this._type == type) { + this._type = undefined; + } } +} - get schemaDefinition(): W['schemaDefinition'] { - assert(this._schemaDefinition, "Badly constructed schema; doesn't have a schema definition"); - return this._schemaDefinition; - } +function buildError(message: string): Error { + // Maybe not the right error for this? + return new GraphQLError(message); +} - /** - * A map of all the types defined on this schema _excluding_ the built-in types. - */ - get types(): ReadonlyMap { - return this.typesMap; - } +export class BuiltIns { + private readonly defaultGraphQLBuiltInTypes: readonly string[] = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; - /** - * 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): W['namedType'] | undefined { - const type = this.typesMap.get(name); - return type ? type : this.builtInTypes.get(name); + addBuiltInTypes(schema: Schema) { + this.defaultGraphQLBuiltInTypes.forEach(t => this.addBuiltInScalar(schema, t)); } - intType(): W['scalarType'] { - return this.builtInTypes.get('Int')! as W['scalarType']; + addBuiltInDirectives(schema: Schema) { + for (const name of ['include', 'skip']) { + this.addBuiltInDirective(schema, name) + .addLocations('FIELD', 'FRAGMENT_SPREAD', 'FRAGMENT_DEFINITION') + .addArgument('if', new NonNullType(schema.booleanType())); + } + this.addBuiltInDirective(schema, 'deprecated') + .addLocations('FIELD_DEFINITION', 'ENUM_VALUE') + .addArgument('reason', schema.stringType(), 'No Longer Supported'); + this.addBuiltInDirective(schema, 'specifiedBy') + .addLocations('SCALAR') + .addArgument('url', new NonNullType(schema.stringType())); } - floatType(): W['scalarType'] { - return this.builtInTypes.get('Float')! as W['scalarType']; + protected addBuiltInScalar(schema: Schema, name: string): ScalarType { + return schema.addType(new ScalarType(name, true)); } - stringType(): W['scalarType'] { - return this.builtInTypes.get('String')! as W['scalarType']; + protected addBuiltInObject(schema: Schema, name: string): ObjectType { + return schema.addType(new ObjectType(name, true)); } - booleanType(): W['scalarType'] { - return this.builtInTypes.get('Boolean')! as W['scalarType']; + protected addBuiltInUnion(schema: Schema, name: string): UnionType { + return schema.addType(new UnionType(name, true)); } - idType(): W['scalarType'] { - return this.builtInTypes.get('ID')! as W['scalarType']; + protected addBuiltInDirective(schema: Schema, name: string): DirectiveDefinition { + return schema.addDirectiveDefinition(new DirectiveDefinition(name, true)); } +} - get directives(): ReadonlyMap { - return this.directivesMap; - } +export class Schema { + private _schemaDefinition: SchemaDefinition; + private readonly _builtInTypes: Map = new Map(); + private readonly _types: Map = new Map(); + private readonly _builtInDirectives: Map = new Map(); + private readonly _directives: Map = new Map(); + private readonly isConstructed: boolean; - directive(name: string): W['directiveDefinition'] | undefined { - const directive = this.directivesMap.get(name); - return directive ? directive : this.builtInDirectives.get(name); + constructor(private readonly builtIns: BuiltIns = graphQLBuiltIns) { + this._schemaDefinition = new SchemaDefinition(); + SchemaElement.prototype['setParent'].call(this._schemaDefinition, this); + builtIns.addBuiltInTypes(this); + builtIns.addBuiltInDirectives(this); + this.isConstructed = true; } - *allSchemaElement(): Generator { - if (this._schemaDefinition) { - yield this._schemaDefinition; - } - for (const type of this.types.values()) { - yield type; - yield* type.allChildrenElements(); - } - for (const directive of this.directives.values()) { - yield directive; - yield* directive.arguments.values(); - } + private canModifyBuiltIn(): boolean { + return !this.isConstructed; } -} -export class Schema extends BaseSchema { - // Note that because typescript typesystem is structural, we need Schema to some incompatible - // properties in Schema that are not in MutableSchema (not having MutableSchema be a subclass - // of Schema is not sufficient). This is the why of the 'mutable' field (the `toMutable` property - // also achieve this in practice, but we could want to add a toMutable() to MutableSchema (that - // just return `this`) for some reason, so the field is a bit clearer/safer). - mutable: false = false; + private removeTypeInternal(type: BaseNamedType) { + this._types.delete(type.name); + } - static parse(source: string | Source, builtIns: BuiltIns = graphQLBuiltIns): Schema { - return buildSchemaInternal(parse(source), builtIns, Ctors.immutable); + private removeDirectiveInternal(definition: DirectiveDefinition) { + this._directives.delete(definition.name); } - toMutable(builtIns?: BuiltIns): MutableSchema { - return copy(this, builtIns ?? this.builtIns, Ctors.mutable); + get schemaDefinition(): SchemaDefinition { + return this._schemaDefinition; } -} -export class MutableSchema extends BaseSchema { - mutable: true = true; + private resetSchemaDefinition() { + this._schemaDefinition = new SchemaDefinition(); + SchemaElement.prototype['setParent'].call(this._schemaDefinition, this); + } - static empty(builtIns: BuiltIns = graphQLBuiltIns): MutableSchema { - return Ctors.mutable.addSchemaDefinition(Ctors.mutable.schema(builtIns)); + /** + * A map of all the types defined on this schema _excluding_ the built-in types. + */ + get types(): ReadonlyMap { + return this._types; } - static parse(source: string | Source, builtIns: BuiltIns = graphQLBuiltIns): MutableSchema { - return buildSchemaInternal(parse(source), builtIns, Ctors.mutable); + /** + * A map of all the built-in types for this schema (those types that will not be displayed + * when printing the schema). + */ + get builtInTypes(): ReadonlyMap { + return this._builtInTypes; } - private ensureTypeNotFound(name: string) { - if (this.type(name)) { - throw new GraphQLError(`Type ${name} already exists in this schema`); - } + /** + * 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); } - private addOrGetType(name: string, kind: string, adder: (n: string) => MutableNamedType) { - // Note: we don't use `this.type(name)` so that `addOrGetScalarType` always throws when called - // with the name of a scalar type. - const existing = this.typesMap.get(name); - if (existing) { - if (existing.kind == kind) { - return existing; - } - throw new GraphQLError(`Type ${name} already exists and is not an ${kind} (it is a ${existing.kind})`); - } - return adder(name); + intType(): ScalarType { + return this._builtInTypes.get('Int')! as ScalarType; } - private addType(name: string, ctor: (n: string) => T): T { - this.ensureTypeNotFound(name); - const newType = ctor(name); - this.typesMap.set(newType.name, newType); - return newType; + floatType(): ScalarType { + return this._builtInTypes.get('Float')! as ScalarType; } - addOrGetObjectType(name: string): MutableObjectType { - return this.addOrGetType(name, 'ObjectType', n => this.addObjectType(n)) as MutableObjectType; + stringType(): ScalarType { + return this._builtInTypes.get('String')! as ScalarType; } - addObjectType(name: string): MutableObjectType { - return this.addType(name, n => Ctors.mutable.createObjectType(n, this, false)); + booleanType(): ScalarType { + return this._builtInTypes.get('Boolean')! as ScalarType; } - addOrGetScalarType(name: string): MutableScalarType { - return this.addOrGetType(name, 'ScalarType', n => this.addScalarType(n)) as MutableScalarType; + idType(): ScalarType { + return this._builtInTypes.get('ID')! as ScalarType; } - addScalarType(name: string): MutableScalarType { - if (this.builtInTypes.has(name)) { - throw new GraphQLError(`Cannot add scalar type of name ${name} as it is a built-in type`); + addType(type: T): T { + if (this.type(type.name)) { + throw buildError(`Type ${type} already exists in this schema`); } - return this.addType(name, n => Ctors.mutable.createScalarType(n, this, false)); + if (type.parent) { + // For convenience, let's not error out on adding an already added type. + if (type.parent == this) { + return type; + } + throw buildError(`Cannot add type ${type} to this schema; it is already attached to another schema`); + } + if (type.isBuiltIn) { + if (!this.isConstructed) { + this._builtInTypes.set(type.name, type); + } else { + throw buildError(`Cannot add built-in ${type} to this schema (built-ins can only be added at schema construction time)`); + } + } else { + this._types.set(type.name, type); + } + SchemaElement.prototype['setParent'].call(type, this); + return type; } - addOrGetUnionType(name: string): MutableUnionType { - return this.addOrGetType(name, 'UnionType', n => this.addUnionType(n)) as MutableUnionType; + get directives(): ReadonlyMap { + return this._directives; } - addUnionType(name: string): MutableUnionType { - return this.addType(name, n => Ctors.mutable.createUnionType(n, this, false)); + /** + * A map of all the built-in directives for this schema (those directives whose definition will not be displayed + * when printing the schema). + */ + get builtInDirectives(): ReadonlyMap { + return this._builtInDirectives; } - addDirective(name: string): MutableDirectiveDefinition { - const directive = Ctors.mutable.createDirectiveDefinition(name, this, false); - this.directivesMap.set(name, directive); - return directive; + directive(name: string): DirectiveDefinition | undefined { + const directive = this._directives.get(name); + return directive ? directive : this._builtInDirectives.get(name); } - toImmutable(builtIns?: BuiltIns): Schema { - return copy(this, builtIns ?? this.builtIns, Ctors.immutable); + *allSchemaElement(): Generator, void, undefined> { + yield this._schemaDefinition; + for (const type of this.types.values()) { + yield type; + yield* type.allChildrenElements(); + } + for (const directive of this.directives.values()) { + yield directive; + yield* directive.arguments.values(); + } } -} -export class SchemaDefinition extends BaseElement { - protected readonly rootsMap: Map = new Map(); + addDirectiveDefinition(name: string): DirectiveDefinition; + addDirectiveDefinition(directive: DirectiveDefinition): DirectiveDefinition; addDirectiveDefinition(directiveOrName: string | DirectiveDefinition): DirectiveDefinition { + const definition = typeof directiveOrName === 'string' ? new DirectiveDefinition(directiveOrName) : directiveOrName; + if (this.directive(definition.name)) { + throw buildError(`Directive ${definition} already exists in this schema`); + } + if (definition.parent) { + // For convenience, let's not error out on adding an already added directive. + if (definition.parent == this) { + return definition; + } + throw buildError(`Cannot add directive ${definition} to this schema; it is already attached to another schema`); + } + if (definition.isBuiltIn) { + if (!this.isConstructed) { + this._builtInDirectives.set(definition.name, definition); + } else { + throw buildError(`Cannot add built-in ${definition} to this schema (built-ins can only be added at schema construction time)`); + } + } else { + this._directives.set(definition.name, definition); + } + SchemaElement.prototype['setParent'].call(definition, this); + return definition; + } - protected constructor( - parent: W['schema'] | W['detached'], - source?: ASTNode - ) { - super(parent, source); + clone(builtIns?: BuiltIns): Schema { + const cloned = new Schema(builtIns ?? this.builtIns); + copy(this, cloned); + return cloned; } +} + +export class SchemaDefinition extends SchemaElement { + readonly kind: 'SchemaDefinition' = 'SchemaDefinition'; + protected readonly _roots: Map = new Map(); get coordinate(): string { return ''; } - kind: 'SchemaDefinition' = 'SchemaDefinition'; - - get roots(): ReadonlyMap { - return this.rootsMap; + get roots(): ReadonlyMap { + return this._roots; } - root(rootType: SchemaRoot): W['objectType'] | undefined { - return this.rootsMap.get(rootType); + root(rootType: SchemaRoot): ObjectType | undefined { + return this._roots.get(rootType); } - protected removeTypeReference(toRemove: W['namedType']): void { - for (const [root, type] of this.rootsMap) { - if (type == toRemove) { - this.rootsMap.delete(root); + setRoot(rootType: SchemaRoot, type: ObjectType): ObjectType; + setRoot(rootType: SchemaRoot, name: string, source?: ASTNode): ObjectType; + setRoot(rootType: SchemaRoot, nameOrType: ObjectType | string, source?: ASTNode): ObjectType { + let toSet: ObjectType; + if (typeof nameOrType === 'string') { + this.checkUpdate(); + const obj = this.schema()!.type(nameOrType); + if (!obj) { + throw new GraphQLError(`Cannot set schema ${rootType} root to unknown type ${nameOrType}`, source); + } else if (obj.kind != 'ObjectType') { + throw new GraphQLError(`Cannot set schema ${rootType} root to non-object type ${nameOrType} (of type ${obj.kind})`, source); } + toSet = obj; + } else { + this.checkUpdate(nameOrType); + toSet = nameOrType; } + this._roots.set(rootType, toSet); + this.source = source; + addReferenceToType(this, toSet); + return toSet; } - toString() { - return `schema[${[...this.rootsMap.keys()].join(', ')}]`; - } -} - -export class MutableSchemaDefinition extends SchemaDefinition implements MutableSchemaElement { - setRoot(rootType: SchemaRoot, objectType: MutableObjectType): MutableObjectType { - this.checkModification(objectType); - this.rootsMap.set(rootType, objectType); - return objectType; - } - - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + protected removeTypeReference(toRemove: NamedType) { + for (const [root, type] of this._roots) { + if (type == toRemove) { + this._roots.delete(root); + } + } } remove(): never[] { if (!this._parent) { return []; } - // We don't want to leave the schema with a SchemaDefinition, so we create an empty one. Note that since we essentially + // We don't want to leave the schema without a SchemaDefinition, so we create an empty one. Note that since we essentially // clear this one so we could leave it (one exception is the source which we don't bother cleaning). But it feels // more consistent not to, so that a schemaElement is consistently always detached after a remove()). - Ctors.mutable.addSchemaDefinition(this._parent); + Schema.prototype['resetSchemaDefinition'].call(this._parent); this._parent = undefined; for (const directive of this._appliedDirectives) { directive.remove(); @@ -558,66 +573,33 @@ export class MutableSchemaDefinition extends SchemaDefinition impl // There can be no other referencers than the parent schema. return []; } -} - -export class ScalarType extends BaseNamedType { - kind: 'ScalarType' = 'ScalarType'; - protected removeTypeReference(_: W['namedType']): void { - assert(false, "Scalar types can never reference other types"); + toString() { + return `schema[${[...this._roots.keys()].join(', ')}]`; } } -export class MutableScalarType extends ScalarType implements MutableSchemaElement { - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); +export class ScalarType extends BaseNamedType { + readonly kind: 'ScalarType' = 'ScalarType'; + + protected removeTypeReference(type: NamedType) { + assert(false, `Scalar type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } - /** - * Removes this type definition from its parent schema. - * - * After calling this method, this type will be "detached": it wil 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 - * referening this type (and have thus now an undefined reference). - */ - remove(): (MutableOutputTypeReferencer | MutableInputTypeReferencer)[] { - if (!this._parent) { - return []; - } - removeTypeDefinition(this, this._parent); - this._parent = undefined; - for (const directive of this._appliedDirectives) { - directive.remove(); - } - const toReturn = [... this._referencers].map(r => { - BaseElement.prototype['removeTypeReference'].call(r, this); - return r as MutableOutputTypeReferencer | MutableInputTypeReferencer; - }); - this._referencers.clear(); - return toReturn; + protected removeInnerElements(): void { + // No inner elements } } -class FieldBasedType extends BaseNamedType { - protected readonly _interfaces: W['interfaceType'][] = []; - protected readonly fieldsMap: Map = new Map(); +abstract class FieldBasedType extends BaseNamedType { + protected readonly _interfaces: InterfaceType[] = []; + protected readonly _fields: Map> = new Map(); - protected constructor( - name: string, - schema: W['schema'] | undefined, - isBuiltIn: boolean, - source?: ASTNode - ) { - super(name, schema, isBuiltIn, source); + private removeFieldInternal(field: FieldDefinition) { + this._fields.delete(field.name); } - get interfaces(): readonly W['interfaceType'][] { + get interfaces(): readonly InterfaceType[] { return this._interfaces; } @@ -625,316 +607,245 @@ class FieldBasedType extends BaseNamedType< return this._interfaces.some(i => i.name == name); } - get fields(): ReadonlyMap { - return this.fieldsMap; + addImplementedInterface(itf: InterfaceType): InterfaceType; + addImplementedInterface(name: string, source?: ASTNode): InterfaceType; + addImplementedInterface(nameOrItf: InterfaceType | string, source?: ASTNode): InterfaceType { + let toAdd: InterfaceType; + if (typeof nameOrItf === 'string') { + this.checkUpdate(); + const itf = this.schema()!.type(nameOrItf); + if (!itf) { + throw new GraphQLError(`Cannot implement unkown type ${nameOrItf}`, source); + } else if (itf.kind != 'InterfaceType') { + throw new GraphQLError(`Cannot implement non-interface type ${nameOrItf} (of type ${itf.kind})`, source); + } + toAdd = itf; + } else { + this.checkUpdate(nameOrItf); + toAdd = nameOrItf; + } + if (!this._interfaces.includes(toAdd)) { + this._interfaces.push(toAdd); + addReferenceToType(this, toAdd); + } + return toAdd; + } + + get fields(): ReadonlyMap> { + return this._fields; } - field(name: string): W['fieldDefinition'] | undefined { - return this.fieldsMap.get(name); + field(name: string): FieldDefinition | undefined { + return this._fields.get(name); } - *allChildrenElements(): Generator { - for (const field of this.fieldsMap.values()) { + addField(field: FieldDefinition): FieldDefinition; + addField(name: string, type?: OutputType): FieldDefinition; + addField(nameOrField: string | FieldDefinition, type?: OutputType): FieldDefinition { + const toAdd = typeof nameOrField === 'string' ? new FieldDefinition(nameOrField) : nameOrField; + this.checkUpdate(toAdd); + if (this.field(toAdd.name)) { + throw buildError(`Field ${toAdd.name} already exists on ${this}`); + } + this._fields.set(toAdd.name, toAdd); + SchemaElement.prototype['setParent'].call(toAdd, this); + // Note that we need to wait we have attached the field to set the type. + if (typeof nameOrField === 'string') { + toAdd.type = type; + } + return toAdd; + } + + *allChildrenElements(): Generator, void, undefined> { + for (const field of this._fields.values()) { yield field; yield* field.arguments.values(); } } - protected removeTypeReference(type: W['namedType']): void { - const index = this._interfaces.indexOf(type as W['interfaceType']); + protected removeTypeReference(type: NamedType) { + const index = this._interfaces.indexOf(type as InterfaceType); if (index >= 0) { this._interfaces.splice(index, 1); } } - protected addFieldInternal(t: T, name: string, type: MutableType): MutableFieldDefinition { - this.checkModification(type); - if (!isOutputType(type)) { - throw new GraphQLError(`Cannot use type ${type} for field ${name} as it is an input type (fields can only use output types)`); + protected removeInnerElements(): void { + for (const field of this._fields.values()) { + field.remove(); } - const field = Ctors.mutable.createFieldDefinition(name, t, type); - this.fieldsMap.set(name, field); - return field; - } - - /** - * Removes this type definition from its parent schema. - * - * After calling this method, this type will be "detached": it wil 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 - * referening this type (and have thus now an undefined reference). - */ - protected removeInternal(t: T): R[] { - if (!this._parent) { - return []; - } - removeTypeDefinition(t, this._parent); - this._parent = undefined; - for (const directive of this._appliedDirectives) { - (directive as MutableDirective).remove(); - } - for (const field of this.fieldsMap.values()) { - (field as MutableFieldDefinition).remove(); - } - const toReturn = [... this._referencers].map(r => { - BaseElement.prototype['removeTypeReference'].call(r, this); - return r as any; - }); - this._referencers.clear(); - return toReturn; } } -export class ObjectType extends FieldBasedType { - kind: 'ObjectType' = 'ObjectType'; +export class ObjectType extends FieldBasedType { + readonly kind: 'ObjectType' = 'ObjectType'; } -export class MutableObjectType extends ObjectType implements MutableSchemaElement { - addField(name: string, type: MutableType): MutableFieldDefinition { - return this.addFieldInternal(this, name, type); - } +export class InterfaceType extends FieldBasedType { + readonly kind: 'InterfaceType' = 'InterfaceType'; - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + allImplementations(): (ObjectType | InterfaceType)[] { + return [...this._referencers].filter(ref => ref.kind === 'ObjectType' || ref.kind === 'InterfaceType') as (ObjectType | InterfaceType)[]; } - /** - * Removes this type definition from its parent schema. - * - * After calling this method, this type will be "detached": it wil 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 - * referening this type (and have thus now an undefined reference). - */ - remove(): MutableObjectTypeReferencer[] { - return this.removeInternal(this); + 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[]; } } -export class InterfaceType extends FieldBasedType { - kind: 'InterfaceType' = 'InterfaceType'; +export class UnionType extends BaseNamedType { + readonly kind: 'UnionType' = 'UnionType'; + protected readonly _types: ObjectType[] = []; - allImplementations(): readonly (W['objectType'] | W['interfaceType'])[] { - return [...this._referencers] as (W['objectType'] | W['interfaceType'])[]; + get types(): readonly ObjectType[] { + return this._types; } - possibleRuntimeTypes(): readonly W['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 W['objectType'][]; - } -} - -export class MutableInterfaceType extends InterfaceType implements MutableSchemaElement { - addField(name: string, type: MutableType): MutableFieldDefinition { - return this.addFieldInternal(this, name, type); + addType(type: ObjectType): ObjectType; + addType(name: string, source?: ASTNode): ObjectType; + addType(nameOrType: ObjectType | string, source?: ASTNode): ObjectType { + let toAdd: ObjectType; + if (typeof nameOrType === 'string') { + this.checkUpdate(); + const obj = this.schema()!.type(nameOrType); + if (!obj) { + throw new GraphQLError(`Cannot implement unkown type ${nameOrType}`, source); + } else if (obj.kind != 'ObjectType') { + throw new GraphQLError(`Cannot implement non-object type ${nameOrType} (of type ${obj.kind})`, source); + } + toAdd = obj; + } else { + this.checkUpdate(nameOrType); + toAdd = nameOrType; + } + if (!this._types.includes(toAdd)) { + this._types.push(toAdd); + addReferenceToType(this, toAdd); + } + return toAdd; } - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + protected removeTypeReference(type: NamedType) { + const index = this._types.indexOf(type as ObjectType); + if (index >= 0) { + this._types.splice(index, 1); + } } - /** - * Removes this type definition from its parent schema. - * - * After calling this method, this type will be "detached": it wil 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 - * referening this type (and have thus now an undefined reference). - */ - remove(): MutableInterfaceTypeReferencer[] { - return this.removeInternal(this); + protected removeInnerElements(): void { + this._types.splice(0, this._types.length); } } -export class UnionType extends BaseNamedType { - protected readonly typesList: W['objectType'][] = []; +export class EnumType extends BaseNamedType { + readonly kind: 'EnumType' = 'EnumType'; + protected readonly _values: EnumValue[] = []; - protected constructor( - name: string, - schema: W['schema'] | W['detached'], - isBuiltIn: boolean, - source?: ASTNode - ) { - super(name, schema, isBuiltIn, source); + get values(): readonly EnumValue[] { + return this._values; } - kind: 'UnionType' = 'UnionType'; - - get types(): readonly W['objectType'][] { - return this.typesList; + value(name: string): EnumValue | undefined { + return this._values.find(v => v.name == name); } - protected removeTypeReference(type: W['namedType']): void { - const index = this.typesList.indexOf(type as W['objectType']); - if (index >= 0) { - this.typesList.splice(index, 1); + 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; } - } -} - -export class MutableUnionType extends UnionType implements MutableSchemaElement { - addType(type: MutableObjectType): void { - this.checkModification(type); - if (!this.typesList.includes(type)) { - this.typesList.push(type); + if (!this._values.includes(toAdd)) { + this._values.push(toAdd); } + return toAdd; } - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + protected removeTypeReference(type: NamedType) { + assert(false, `Eum type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } - /** - * Removes this type definition from its parent schema. - * - * After calling this method, this type will be "detached": it wil 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 - * referening this type (and have thus now an undefined reference). - */ - remove(): MutableOutputTypeReferencer[] { - if (!this._parent) { - return []; - } - removeTypeDefinition(this, this._parent); - this._parent = undefined; - for (const directive of this._appliedDirectives) { - directive.remove(); + private removeValueInternal(value: EnumValue) { + const index = this._values.indexOf(value); + if (index >= 0) { + this._values.splice(index, 1); } - this.typesList.splice(0, this.typesList.length); - const toReturn = [... this._referencers].map(r => { - BaseElement.prototype['removeTypeReference'].call(r, this); - return r as MutableOutputTypeReferencer; - }); - this._referencers.clear(); - return toReturn; } -} - -export class InputObjectType extends BaseNamedType { - protected readonly fieldsMap: Map = new Map(); - protected constructor( - name: string, - schema: W['schema'] | undefined, - isBuiltIn: boolean, - source?: ASTNode - ) { - super(name, schema, isBuiltIn, source); + protected removeInnerElements(): void { + this._values.splice(0, this._values.length); } +} - kind: 'InputObjectType' = 'InputObjectType'; - - get fields(): ReadonlyMap { - return this.fieldsMap; - } +export class InputObjectType extends BaseNamedType { + readonly kind: 'InputObjectType' = 'InputObjectType'; + private readonly _fields: Map = new Map(); - field(name: string): W['inputFieldDefinition'] | undefined { - return this.fieldsMap.get(name); + get fields(): ReadonlyMap { + return this._fields; } - *allChildrenElements(): Generator { - yield* this.fieldsMap.values(); + field(name: string): InputFieldDefinition | undefined { + return this._fields.get(name); } - protected removeTypeReference(_: W['namedType']): void { - assert(false, "Input object types can never reference other types directly (their field does)"); + addField(field: InputFieldDefinition): InputFieldDefinition; + addField(name: string, type?: InputType): InputFieldDefinition; + addField(nameOrField: string | InputFieldDefinition, type?: InputType): InputFieldDefinition { + const toAdd = typeof nameOrField === 'string' ? new InputFieldDefinition(nameOrField) : nameOrField; + this.checkUpdate(toAdd); + if (this.field(toAdd.name)) { + throw buildError(`Field ${toAdd.name} already exists on ${this}`); + } + this._fields.set(toAdd.name, toAdd); + SchemaElement.prototype['setParent'].call(toAdd, this); + // Note that we need to wait we have attached the field to set the type. + if (typeof nameOrField === 'string') { + toAdd.type = type; + } + return toAdd; } -} -export class MutableInputObjectType extends InputObjectType implements MutableSchemaElement { - addField(name: string, type: MutableType): MutableInputFieldDefinition { - this.checkModification(type); - if (!isInputType(type)) { - throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an output type (input fields can only use input types)`); - } - const field = Ctors.mutable.createInputFieldDefinition(name, this, type); - this.fieldsMap.set(name, field); - return field; + *allChildrenElements(): Generator, void, undefined> { + yield* this._fields.values(); } - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + protected removeTypeReference(type: NamedType) { + assert(false, `Input Object type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } - /** - * Removes this type definition from its parent schema. - * - * After calling this method, this type will be "detached": it wil 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 - * referening this type (and have thus now an undefined reference). - */ - remove(): MutableInputTypeReferencer[] { - if (!this._parent) { - return []; - } - removeTypeDefinition(this, this._parent); - this._parent = undefined; - for (const directive of this._appliedDirectives) { - directive.remove(); - } - for (const field of this.fieldsMap.values()) { + protected removeInnerElements(): void { + for (const field of this._fields.values()) { field.remove(); } - const toReturn = [... this._referencers].map(r => { - BaseElement.prototype['removeTypeReference'].call(r, this); - return r as MutableInputTypeReferencer; - }); - this._referencers.clear(); - return toReturn; } -} -export function listType(type: T): ListType { - return Ctors.immutable.createList(type); -} - -export function mutableListType(type: T): MutableListType { - return Ctors.mutable.createList(type); + private removeFieldInternal(field: InputFieldDefinition) { + this._fields.delete(field.name); + } } -export class ListType { - protected constructor(protected _type: T) {} +export class ListType { + readonly kind: 'ListType' = 'ListType'; - kind: 'ListType' = 'ListType'; + constructor(protected _type: T) {} - schema(): W['schema'] { - return this.baseType().schema() as W['schema']; + schema(): Schema { + return this.baseType().schema() as Schema; } get ofType(): T { return this._type; } - baseType(): W['namedType'] { - return isWrapperType(this._type) ? this._type.baseType() : this._type as W['namedType']; + baseType(): NamedType { + return isWrapperType(this._type) ? this._type.baseType() : this._type as NamedType; } toString(): string { @@ -942,34 +853,21 @@ export class ListType { } } -export class MutableListType extends ListType {} +export class NonNullType { + readonly kind: 'NonNullType' = 'NonNullType'; -export type NullableType = W['namedType'] | W['listType']; -export type MutableNullableType = NullableType; + constructor(protected _type: T) {} -export function nonNullType(type: T): NonNullType { - return Ctors.immutable.createNonNull(type); -} - -export function mutableNonNullType(type: T): MutableNonNullType { - return Ctors.mutable.createNonNull(type); -} - -export class NonNullType, W extends World = ImmutableWorld> { - protected constructor(protected _type: T) {} - - kind: 'NonNullType' = 'NonNullType'; - - schema(): W['schema'] { - return this.baseType().schema() as W['schema']; + schema(): Schema { + return this.baseType().schema() as Schema; } get ofType(): T { return this._type; } - baseType(): W['namedType'] { - return isWrapperType(this._type) ? this._type.baseType() : this._type as W['namedType']; + baseType(): NamedType { + return isWrapperType(this._type) ? this._type.baseType() : this._type as NamedType; } toString(): string { @@ -977,65 +875,43 @@ export class NonNullType, W extends World = ImmutableW } } -export class MutableNonNullType extends NonNullType {} - -export class FieldDefinition

extends BaseNamedElement { - protected readonly _args: Map = new Map(); - - protected constructor( - name: string, - parent: P | W['detached'], - protected _type: W['outputType'] | W['detached'], - source?: ASTNode - ) { - super(name, parent, source); - } - - kind: 'FieldDefinition' = 'FieldDefinition'; +export class FieldDefinition

extends BaseNamedElementWithType { + readonly kind: 'FieldDefinition' = 'FieldDefinition'; + private readonly _args: Map>> = new Map(); get coordinate(): string { const parent = this.parent; return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } - get type(): W['outputType'] | W['detached'] { - return this._type; - } - - get arguments(): ReadonlyMap { + get arguments(): ReadonlyMap>> { return this._args; } - argument(name: string): W['fieldArgumentDefinition'] | undefined { + argument(name: string): ArgumentDefinition> | undefined { return this._args.get(name); } - protected removeTypeReference(type: W['namedType']): void { - if (this._type == type) { - this._type = undefined; + addArgument(arg: ArgumentDefinition>): ArgumentDefinition>; + addArgument(name: string, type?: InputType, defaultValue?: any): ArgumentDefinition>; + addArgument(nameOrArg: string | ArgumentDefinition>, type?: InputType, defaultValue?: any): ArgumentDefinition> { + const toAdd = typeof nameOrArg === 'string' ? new ArgumentDefinition>(nameOrArg).setDefaultValue(defaultValue) : nameOrArg; + if (this.argument(toAdd.name)) { + throw buildError(`Argument ${toAdd.name} already exists on field ${this.name}`); } - } - - toString(): string { - const args = this._args.size == 0 - ? "" - : '(' + [...this._args.values()].map(arg => arg.toString()).join(', ') + ')'; - return `${this.name}${args}: ${this.type}`; - } -} - -export class MutableFieldDefinition

extends FieldDefinition implements MutableSchemaElement { - setType(type: MutableType): MutableFieldDefinition

{ - this.checkModification(type); - if (!isOutputType(type)) { - throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an input type (fields can only use output types)`); + if (toAdd.parent) { + // For convenience, let's not error out on adding an already added type. + if (toAdd.parent === this) { + return toAdd; + } + throw buildError(`Cannot add argument ${toAdd.name} to this instance of field ${this.name}; it is already attached to another schema`); } - this._type = type; - return this; - } - - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + this._args.set(toAdd.name, toAdd); + SchemaElement.prototype['setParent'].call(toAdd, this); + if (typeof nameOrArg === 'string') { + toAdd.type = type; + } + return toAdd; } /** @@ -1048,63 +924,30 @@ export class MutableFieldDefinition

>).delete(this.name); - // We "clean" all the attributes of the object. This is because we mean detached element to be essentially - // dead and meant to be GCed and this ensure we don't prevent that for no good reason. + FieldBasedType.prototype['removeFieldInternal'].call(this._parent, this); this._parent = undefined; - this._type = undefined; + this.type = undefined; for (const arg of this._args.values()) { arg.remove(); } // Fields have nothing that can reference them outside of their parents return []; } -} - -export class InputFieldDefinition extends BaseNamedElement { - protected constructor( - name: string, - parent: W['inputObjectType'] | W['detached'], - protected _type: W['inputType'] | W['detached'], - source?: ASTNode - ) { - super(name, parent, source); - } - - get coordinate(): string { - const parent = this.parent; - return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; - } - - kind: 'InputFieldDefinition' = 'InputFieldDefinition'; - - get type(): W['inputType'] | W['detached'] { - return this._type; - } - - protected removeTypeReference(type: W['namedType']): void { - if (this._type == type) { - this._type = undefined; - } - } toString(): string { - return `${this.name}: ${this.type}`; + const args = this._args.size == 0 + ? "" + : '(' + [...this._args.values()].map(arg => arg.toString()).join(', ') + ')'; + return `${this.name}${args}: ${this.type}`; } } -export class MutableInputFieldDefinition extends InputFieldDefinition implements MutableSchemaElement { - setType(type: MutableType): MutableInputFieldDefinition { - this.checkModification(type); - if (!isInputType(type)) { - throw new GraphQLError(`Cannot use type ${type} for field ${this.name} as it is an output type (input fields can only use input types)`); - } - this._type = type; - return this; - } +export class InputFieldDefinition extends BaseNamedElementWithType { + readonly kind: 'InputFieldDefinition' = 'InputFieldDefinition'; - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + get coordinate(): string { + const parent = this.parent; + return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } /** @@ -1117,57 +960,38 @@ export class MutableInputFieldDefinition extends InputFieldDefinition).delete(this.name); - // We "clean" all the attributes of the object. This is because we mean detached element to be essentially - // dead and meant to be GCed and this ensure we don't prevent that for no good reason. + InputObjectType.prototype['removeFieldInternal'].call(this._parent, this); this._parent = undefined; - this._type = undefined; + this.type = undefined; // Fields have nothing that can reference them outside of their parents return []; } -} -export class ArgumentDefinition

extends BaseNamedElement { - protected constructor( - name: string, - parent: P | W['detached'], - protected _type: W['inputType'] | W['detached'], - protected _defaultValue: any, - source?: ASTNode - ) { - super(name, parent, source); + toString(): string { + return `${this.name}: ${this.type}`; } +} - kind: 'ArgumentDefinition' = 'ArgumentDefinition'; +export class ArgumentDefinition

| DirectiveDefinition> extends BaseNamedElementWithType { + readonly kind: 'ArgumentDefinition' = 'ArgumentDefinition'; + private _defaultValue?: any + + constructor(name: string) { + super(name); + } get coordinate(): string { const parent = this.parent; return `${parent == undefined ? '' : parent.coordinate}(${this.name}:)`; } - get type(): W['inputType'] | W['detached'] { - return this._type; - } - - defaultValue(): any { + get defaultValue(): any { return this._defaultValue; } - protected removeTypeReference(type: W['namedType']): void { - if (this._type == type) { - this._type = undefined; - } - } - - toString() { - const defaultStr = this._defaultValue == undefined ? "" : ` = ${this._defaultValue}`; - return `${this.name}: ${this._type}${defaultStr}`; - } -} - -export class MutableArgumentDefinition

| MutableDirectiveDefinition> extends ArgumentDefinition implements MutableSchemaElement { - applyDirective(definition: MutableDirectiveDefinition, args?: Map): MutableDirective { - return this.addAppliedDirective(Ctors.mutable.createDirective(definition.name, this, args ?? new Map())); + setDefaultValue(defaultValue: any): ArgumentDefinition

{ + this._defaultValue = defaultValue; + return this; } /** @@ -1181,98 +1005,103 @@ export class MutableArgumentDefinition

| M return []; } (this._parent.arguments as Map).delete(this.name); - // We "clean" all the attributes of the object. This is because we mean detached element to be essentially - // dead and meant to be GCed and this ensure we don't prevent that for no good reason. this._parent = undefined; - this._type = undefined; + this.type = undefined; this._defaultValue = undefined; return []; } -} - -export class DirectiveDefinition extends BaseNamedElement { - protected readonly _args: Map = new Map(); - protected _repeatable: boolean = false; - protected readonly _locations: DirectiveLocationEnum[] = []; - protected readonly _referencers: Set = new Set(); - protected constructor( - name: string, - schema: W['schema'] | W['detached'], - readonly isBuiltIn: boolean, - source?: ASTNode - ) { - super(name, schema, source); + toString() { + const defaultStr = this._defaultValue == undefined ? "" : ` = ${this._defaultValue}`; + return `${this.name}: ${this.type}${defaultStr}`; } +} - kind: 'Directive' = 'Directive'; +export class EnumValue extends BaseNamedElement { + readonly kind: 'EnumValue' = 'EnumValue'; get coordinate(): string { - return `@{this.name}`; + const parent = this.parent; + return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } - get arguments(): ReadonlyMap { - return this._args; + /** + * Removes this field definition from its parent type. + * + * After calling this method, this field definition will be "detached": it wil have no parent, schema, type, + * arguments or directives. + */ + remove(): never[] { + if (!this._parent) { + return []; + } + EnumType.prototype['removeValueInternal'].call(this._parent, this); + this._parent = undefined; + // Enum values have nothing that can reference them outside of their parents + // TODO: that's actually not true if you include arguments (both default value in definition and concrete directive application). + return []; } - argument(name: string): W['directiveArgumentDefinition'] | undefined { - return this._args.get(name); + protected removeTypeReference(type: NamedType) { + assert(false, `Enum value ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); } - get repeatable(): boolean { - return this._repeatable; + toString(): string { + return `${this.name}$`; } +} - get locations(): readonly DirectiveLocationEnum[] { - return this._locations; - } +export class DirectiveDefinition extends BaseNamedElement { + readonly kind: 'DirectiveDefinition' = 'DirectiveDefinition'; + + private readonly _args: Map> = new Map(); + repeatable: boolean = false; + private readonly _locations: DirectiveLocationEnum[] = []; + private readonly _referencers: Set = new Set(); - protected removeTypeReference(_: W['namedType']): void { - assert(false, "Directive definitions can never reference other types directly (their arguments might)"); + constructor(name: string, readonly isBuiltIn: boolean = false) { + super(name); } - private addReferencer(referencer: W['directive']) { - assert(referencer, 'Referencer should exists'); - this._referencers.add(referencer); + get coordinate(): string { + return `@{this.name}`; } - protected setRepeatableInternal(repeatable: boolean) { - this._repeatable = repeatable; + get arguments(): ReadonlyMap> { + return this._args; } - toString(): string { - return this.name; + argument(name: string): ArgumentDefinition | undefined { + return this._args.get(name); } -} -export class MutableDirectiveDefinition extends DirectiveDefinition implements MutableSchemaElement { - protected checkModification(addedElement?: { schema(): MutableSchema | undefined}) { - // We cannot modify built-in, unless they are not attached. - if (this.isBuiltIn) { - if (this.schema()) { - throw Error(`Cannot modify built-in ${this}`); + addArgument(arg: ArgumentDefinition): ArgumentDefinition; + addArgument(name: string, type?: InputType, defaultValue?: any): ArgumentDefinition; + addArgument(nameOrArg: string | ArgumentDefinition, type?: InputType, defaultValue?: any): ArgumentDefinition { + const toAdd = typeof nameOrArg === 'string' ? new ArgumentDefinition(nameOrArg).setDefaultValue(defaultValue) : nameOrArg; + if (this.argument(toAdd.name)) { + throw buildError(`Argument ${toAdd.name} already exists on field ${this.name}`); + } + if (toAdd.parent) { + // For convenience, let's not error out on adding an already added type. + if (toAdd.parent === this) { + return toAdd; } - } else { - super.checkModification(addedElement); + throw buildError(`Cannot add argument ${toAdd.name} to this instance of field ${this.name}; it is already attached to another schema`); } - } - - addArgument(name: string, type: MutableType, defaultValue?: any): MutableArgumentDefinition { - this.checkModification(type); - if (!isInputType(type)) { - throw new GraphQLError(`Cannot use type ${type} for field ${name} as it is an output type (directive definition arguments can only use input types)`); + this._args.set(toAdd.name, toAdd); + SchemaElement.prototype['setParent'].call(toAdd, this); + if (typeof nameOrArg === 'string') { + toAdd.type = type; } - const newArg = Ctors.mutable.createDirectiveArgumentDefinition(name, this, type, defaultValue); - this._args.set(name, newArg); - return newArg; + return toAdd; } - setRepeatable(repeatable: boolean = true): MutableDirectiveDefinition { - this.setRepeatableInternal(repeatable); - return this;; + get locations(): readonly DirectiveLocationEnum[] { + return this._locations; } - addLocations(...locations: DirectiveLocationEnum[]): MutableDirectiveDefinition { + addLocations(...locations: DirectiveLocationEnum[]): DirectiveDefinition { for (const location of locations) { if (!this._locations.includes(location)) { this._locations.push(location); @@ -1281,15 +1110,15 @@ export class MutableDirectiveDefinition extends DirectiveDefinition= 0) { @@ -1299,11 +1128,20 @@ export class MutableDirectiveDefinition extends DirectiveDefinition implements Named { - protected constructor( - readonly name: string, - protected _parent: W['schemaElement'] | W['detached'], - protected _args: Map, - readonly source?: ASTNode - ) { + toString(): string { + return this.name; } +} + +export class Directive implements Named { + private _parent?: SchemaElement; + source?: ASTNode; - schema(): W['schema'] | W['detached'] { + constructor(readonly name: string, private _args: Map) {} + + schema(): Schema | undefined { return this._parent?.schema(); } - get parent(): W['schemaElement'] | W['detached'] { + get parent(): SchemaElement | undefined { return this._parent; } - get definition(): W['directiveDefinition'] | W['detached'] { + private setParent(parent: SchemaElement) { + assert(!this._parent, "Cannot set parent of a non-detached directive"); + this._parent = parent; + } + + get definition(): DirectiveDefinition | undefined { const doc = this.schema(); return doc?.directive(this.name); } @@ -1364,13 +1208,6 @@ export class Directive implements Named { return true; } - toString(): string { - const args = this._args.size == 0 ? '' : '(' + [...this._args.entries()].map(([n, v]) => `${n}: ${valueToString(v)}`).join(', ') + ')'; - return `@${this.name}${args}`; - } -} - -export class MutableDirective extends Directive { /** * Removes this directive application from its parent type. * @@ -1380,183 +1217,17 @@ export class MutableDirective extends Directive { if (!this._parent) { return false; } - const parentDirectives = this._parent.appliedDirectives as MutableDirective[]; + const parentDirectives = this._parent.appliedDirectives as Directive[]; const index = parentDirectives.indexOf(this); assert(index >= 0, `Directive ${this} lists ${this._parent} as parent, but that parent doesn't list it as applied directive`); parentDirectives.splice(index, 1); this._parent = undefined; return true; } -} - -class Ctors { - // The definitions below are a hack to work around that typescript does not have "module" visibility for class constructors. - // Meaning, we don't want the constructors below to be exposed (because it would be way too easy to break some of the class - // invariants if using them, and more generally we don't want users to care about that level of detail), so all those ctors - // are protected, but we still need to access them here, hence the `Function.prototype` hack. - // Note: this is fairly well contained so manageable but certainly ugly and a bit error-prone, so if someone knowns a better way? - static immutable = new Ctors( - (builtIns, ctors) => new (Function.prototype.bind.call(Schema, null, builtIns, ctors)), - (parent, source) => new (Function.prototype.bind.call(SchemaDefinition, null, parent, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(ScalarType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(ObjectType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(InterfaceType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(UnionType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(InputObjectType, null, name, doc, builtIn, source)), - (type) => new (Function.prototype.bind.call(ListType, null, type)), - (type) => new (Function.prototype.bind.call(NonNullType, null, type)), - (name, parent, type, source) => new (Function.prototype.bind.call(FieldDefinition, null, name, parent, type, source)), - (name, parent, type, source) => new (Function.prototype.bind.call(InputFieldDefinition, null, name, parent, type, source)), - (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), - (name, parent, type, value, source) => new (Function.prototype.bind.call(ArgumentDefinition, null, name, parent, type, value, source)), - (name, parent, builtIn, source) => new (Function.prototype.bind.call(DirectiveDefinition, null, name, parent, builtIn, source)), - (name, parent, args, source) => new (Function.prototype.bind.call(Directive, null, name, parent, args, source)), - (v) => { - if (v == undefined) - // TODO: Better error; maybe pass a string to include so the message is more helpful. - throw new Error("Invalid detached value"); - return v; - } - ); - - static mutable = new Ctors( - (builtIns, ctors) => new (Function.prototype.bind.call(MutableSchema, null, builtIns, ctors)), - (parent, source) => new (Function.prototype.bind.call(MutableSchemaDefinition, null, parent, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableScalarType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableObjectType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableInterfaceType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableUnionType, null, name, doc, builtIn, source)), - (name, doc, builtIn, source) => new (Function.prototype.bind.call(MutableInputObjectType, null, name, doc, builtIn, source)), - (type) => new (Function.prototype.bind.call(MutableListType, null, type)), - (type) => new (Function.prototype.bind.call(MutableNonNullType, null, type)), - (name, parent, type, source) => new (Function.prototype.bind.call(MutableFieldDefinition, null, name, parent, type, source)), - (name, parent, type, source) => new (Function.prototype.bind.call(MutableInputFieldDefinition, null, name, parent, type, source)), - (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), - (name, parent, type, value, source) => new (Function.prototype.bind.call(MutableArgumentDefinition, null, name, parent, type, value, source)), - (name, parent, builtIn, source) => new (Function.prototype.bind.call(MutableDirectiveDefinition, null, name, parent, builtIn, source)), - (name, parent, args, source) => new (Function.prototype.bind.call(MutableDirective, null, name, parent, args, source)), - (v) => v - ); - - constructor( - private readonly createSchema: (builtIns: BuiltIns, ctors: Ctors) => W['schema'], - private readonly createSchemaDefinition: (parent: W['schema'] | W['detached'], source?: ASTNode) => W['schemaDefinition'], - readonly createScalarType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['scalarType'], - readonly createObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['objectType'], - readonly createInterfaceType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['interfaceType'], - readonly createUnionType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['unionType'], - readonly createInputObjectType: (name: string, schema: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['inputObjectType'], - readonly createList: (type: T) => W['listType'], - readonly createNonNull: (type: T) => W['nonNullType'], - readonly createFieldDefinition: (name: string, parent: W['objectType'] | W['interfaceType'] | W['detached'], type: W['outputType'], source?: ASTNode) => W['fieldDefinition'], - readonly createInputFieldDefinition: (name: string, parent: W['inputObjectType'] | W['detached'], type: W['inputType'], source?: ASTNode) => W['inputFieldDefinition'], - readonly createFieldArgumentDefinition: (name: string, parent: W['fieldDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['fieldArgumentDefinition'], - readonly createDirectiveArgumentDefinition: (name: string, parent: W['directiveDefinition'] | W['detached'], type: W['inputType'], defaultValue: any, source?: ASTNode) => W['directiveArgumentDefinition'], - readonly createDirectiveDefinition: (name: string, parent: W['schema'] | W['detached'], isBuiltIn: boolean, source?: ASTNode) => W['directiveDefinition'], - readonly createDirective: (name: string, parent: W['schemaElement'] | W['detached'], args: Map, source?: ASTNode) => W['directive'], - readonly checkDetached: (v: T | undefined) => T | W['detached'] - ) { - } - - schema(builtIns: BuiltIns) { - return this.createSchema(builtIns, this); - } - - addSchemaDefinition(schema: W['schema'], source?: ASTNode): W['schema'] { - const schemaDefinition = this.createSchemaDefinition(schema, source); - BaseSchema.prototype['setSchemaDefinition'].call(schema, schemaDefinition); - return schema; - } - - createNamedType(kind: string, name: string, schema: W['schema'], isBuiltIn: boolean, source?: ASTNode): W['namedType'] { - switch (kind) { - case 'ScalarType': - return this.createScalarType(name, schema, isBuiltIn, source); - case 'ObjectType': - return this.createObjectType(name, schema, isBuiltIn, source); - case 'InterfaceType': - return this.createInterfaceType(name, schema, isBuiltIn, source); - case 'UnionType': - return this.createUnionType(name, schema, isBuiltIn, source); - case 'InputObjectType': - return this.createInputObjectType(name, schema, isBuiltIn, source); - default: - assert(false, "Missing branch for type " + kind); - } - } -} - -export class BuiltIns { - private readonly defaultGraphQLBuiltInTypes: readonly string[] = [ 'Int', 'Float', 'String', 'Boolean', 'ID' ]; - private readonly _builtInTypes = new Map(); - private readonly _builtInDirectives = new Map(); - - constructor() { - this.populateBuiltInTypes(); - this.populateBuiltInDirectives(); - } - - isBuiltInType(name: string) { - return this._builtInTypes.has(name);; - } - - isBuiltInDirective(name: string) { - return this._builtInDirectives.has(name); - } - - builtInTypes(): IterableIterator { - return this._builtInTypes.values(); - } - - builtInDirectives(): IterableIterator { - return this._builtInDirectives.values(); - } - - protected populateBuiltInTypes(): void { - this.defaultGraphQLBuiltInTypes.forEach(t => this.addScalarType(t)) - } - - protected populateBuiltInDirectives(): void { - for (const name of ['include', 'skip']) { - this.addDirective(name) - .addLocations('FIELD', 'FRAGMENT_SPREAD', 'FRAGMENT_DEFINITION') - .addArgument('if', mutableNonNullType(this.getType('Boolean'))); - } - this.addDirective('deprecated') - .addLocations('FIELD_DEFINITION', 'ENUM_VALUE') - .addArgument('reason', this.getType('String'), 'No Longer Supported'); - this.addDirective('specifiedBy') - .addLocations('SCALAR') - .addArgument('url', mutableNonNullType(this.getType('String'))); - } - - protected getType(name: string): MutableNamedType { - const type = this._builtInTypes.get(name); - assert(type, `Cannot find built-in type ${name}`); - return type; - } - - private addType(type: T): T { - this._builtInTypes.set(type.name, type); - return type; - } - - protected addScalarType(name: string): MutableScalarType { - return this.addType(Ctors.mutable.createScalarType(name, undefined, true)); - } - - protected addObjectType(name: string): MutableObjectType { - return this.addType(Ctors.mutable.createObjectType(name, undefined, true)); - } - protected addUnionType(name: string): MutableUnionType { - return this.addType(Ctors.mutable.createUnionType(name, undefined, true)); - } - - protected addDirective(name: string): MutableDirectiveDefinition { - const directive = Ctors.mutable.createDirectiveDefinition(name, undefined, true); - this._builtInDirectives.set(directive.name, directive); - return directive; + toString(): string { + const args = this._args.size == 0 ? '' : '(' + [...this._args.entries()].map(([n, v]) => `${n}: ${valueToString(v)}`).join(', ') + ')'; + return `@${this.name}${args}`; } } @@ -1570,66 +1241,13 @@ function valueEquals(a: any, b: any): boolean { return deepEqual(a, b); } -function addTypeDefinition(namedType: W['namedType'], schema: W['schema']) { - (schema.types as Map).set(namedType.name, namedType); -} - -function removeTypeDefinition(namedType: W['namedType'], schema: W['schema']) { - if (namedType.isBuiltIn) { - throw Error(`Cannot remove built-in type ${namedType.name}`); - } - (schema.types as Map).delete(namedType.name); -} - -function addDirectiveDefinition(definition: W['directiveDefinition'], schema: W['schema']) { - (schema.directives as Map).set(definition.name, definition); -} - -function removeDirectiveDefinition(definition: W['directiveDefinition'], schema: W['schema']) { - if (definition.isBuiltIn) { - throw Error(`Cannot remove built-in directive ${definition.name}`); - } - (schema.directives as Map).delete(definition.name); -} - -function addRoot(root: SchemaRoot, typeName: string, schemaDefinition: W['schemaDefinition']) { - const type = schemaDefinition.schema()!.type(typeName)! as W['objectType']; - (schemaDefinition.roots as Map).set(root, type); - addReferencerToType(schemaDefinition, type); -} - -function addFieldArg(arg: W['fieldArgumentDefinition'], field: W['fieldDefinition']) { - (field.arguments as Map).set(arg.name, arg); -} - -function addDirectiveArg(arg: W['directiveArgumentDefinition'], directive: W['directiveDefinition']) { - (directive.arguments as Map).set(arg.name, arg); -} - -function addField(field: W['fieldDefinition'] | W['inputFieldDefinition'], objectType: W['objectType'] | W['interfaceType'] | W['inputObjectType']) { - (objectType.fields as Map).set(field.name, field); -} - -function addTypeToUnion(typeName: string, unionType: W['unionType']) { - const type = unionType.schema()!.type(typeName)! as W['objectType']; - (unionType.types as W['objectType'][]).push(type); - addReferencerToType(unionType, type); -} - -function addImplementedInterface(implemented: W['interfaceType'], implementer: W['interfaceType'] | W['objectType']) { - if (!implementer.implementsInterface(implemented.name)) { - (implementer.interfaces as W['interfaceType'][]).push(implemented); - addReferencerToType(implementer, implemented); - } -} - -function addReferencerToType(referencer: W['schemaElement'], type: W['type']) { +function addReferenceToType(referencer: SchemaElement, type: Type) { switch (type.kind) { case 'ListType': - addReferencerToType(referencer, (type as W['listType']).baseType()); + addReferenceToType(referencer, type.baseType()); break; case 'NonNullType': - addReferencerToType(referencer, (type as W['nonNullType']).baseType()); + addReferenceToType(referencer, type.baseType()); break; default: BaseNamedType.prototype['addReferencer'].call(type, referencer); @@ -1637,370 +1255,169 @@ function addReferencerToType(referencer: W['schemaElement'], ty } } -function addReferencerToDirectiveDefinition(referencer: W['directive'], definition: W['directiveDefinition']) { - DirectiveDefinition.prototype['addReferencer'].call(definition, referencer); -} - -function setDirectiveDefinitionRepeatableAndLocations(definition: W['directiveDefinition'], repeatable: boolean, locations: readonly DirectiveLocationEnum[]) { - DirectiveDefinition.prototype['setRepeatableInternal'].call(definition, repeatable); - (definition.locations as DirectiveLocationEnum[]).push(...locations); -} - -function buildValue(value?: ValueNode): any { - // TODO: Should we rewrite a version of valueFromAST instead of using valueFromASTUntyped? Afaict, what we're missing out on is - // 1) coercions, which concretely, means: - // - for enums, we get strings - // - for int, we don't get the validation that it should be a 32bit value. - // - for ID, which accepts strings and int, we don't get int converted to string. - // - for floats, we get either int or float, we don't get int converted to float. - // - we don't get any custom coercion (but neither is buildSchema in graphQL-js anyway). - // 2) type validation. - return value ? valueFromASTUntyped(value) : undefined; -} - -function buildSchemaInternal(documentNode: DocumentNode, builtIns: BuiltIns, ctors: Ctors): W['schema'] { - const doc = ctors.schema(builtIns); - buildNamedTypeAndDirectivesShallow(documentNode, doc, ctors); - for (const definitionNode of documentNode.definitions) { - switch (definitionNode.kind) { - case 'OperationDefinition': - case 'FragmentDefinition': - throw new GraphQLError("Invalid executable definition found while building schema", definitionNode); - case 'SchemaDefinition': - buildSchemaDefinition(definitionNode, doc, ctors); - break; - case 'ScalarTypeDefinition': - case 'ObjectTypeDefinition': - case 'InterfaceTypeDefinition': - case 'UnionTypeDefinition': - case 'EnumTypeDefinition': - case 'InputObjectTypeDefinition': - buildNamedTypeInner(definitionNode, doc.type(definitionNode.name.value)!, ctors); - break; - case 'DirectiveDefinition': - buildDirectiveDefinitionInner(definitionNode, doc.directive(definitionNode.name.value)!, ctors); - break; - case 'SchemaExtension': - case 'ScalarTypeExtension': - case 'ObjectTypeExtension': - case 'InterfaceTypeExtension': - case 'UnionTypeExtension': - case 'EnumTypeExtension': - case 'InputObjectTypeExtension': - throw new Error("Extensions are a TODO"); - } - } - return doc; -} - -function buildNamedTypeAndDirectivesShallow(documentNode: DocumentNode, schema: W['schema'], ctors: Ctors) { - for (const definitionNode of documentNode.definitions) { - switch (definitionNode.kind) { - case 'ScalarTypeDefinition': - case 'ObjectTypeDefinition': - case 'InterfaceTypeDefinition': - case 'UnionTypeDefinition': - case 'EnumTypeDefinition': - case 'InputObjectTypeDefinition': - addTypeDefinition(ctors.createNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value, schema, false, definitionNode), schema); - break; - case 'SchemaExtension': - case 'ScalarTypeExtension': - case 'ObjectTypeExtension': - case 'InterfaceTypeExtension': - case 'UnionTypeExtension': - case 'EnumTypeExtension': - case 'InputObjectTypeExtension': - throw new Error("Extensions are a TODO"); - case 'DirectiveDefinition': - addDirectiveDefinition(ctors.createDirectiveDefinition(definitionNode.name.value, schema, false, definitionNode), schema); - break; - } - } -} - -type NodeWithDirectives = {directives?: ReadonlyArray}; - -function withoutTrailingDefinition(str: string): string { - return str.slice(0, str.length - 'Definition'.length); -} - -function getReferencedType(schema: W['schema'], node: NamedTypeNode): W['namedType'] { - const type = schema.type(node.name.value); - if (!type) { - throw new GraphQLError(`Unknown type ${node.name.value}`, node); - } - return type; -} - -function buildSchemaDefinition(schemaNode: SchemaDefinitionNode, schema: W['schema'], ctors: Ctors) { - ctors.addSchemaDefinition(schema, schemaNode); - buildAppliedDirectives(schemaNode, schema.schemaDefinition, ctors); - for (const opTypeNode of schemaNode.operationTypes) { - addRoot(opTypeNode.operation, opTypeNode.type.name.value, schema.schemaDefinition); - } -} - -function buildAppliedDirectives(elementNode: NodeWithDirectives, element: W['schemaElement'], ctors: Ctors) { - for (const directive of elementNode.directives ?? []) { - BaseElement.prototype['addAppliedDirective'].call(element, buildDirective(directive, element, ctors)); - } -} - -function buildDirective(directiveNode: DirectiveNode, element: W['schemaElement'], ctors: Ctors): W['directive'] { - const args = new Map(); - for (const argNode of directiveNode.arguments ?? []) { - args.set(argNode.name.value, buildValue(argNode.value)); - } - const directive = ctors.createDirective(directiveNode.name.value, element, args, directiveNode); - const definition = directive.definition; - if (!definition) { - throw new GraphQLError(`Unknown directive "@${directive.name}".`, directiveNode); - } - addReferencerToDirectiveDefinition(directive, definition); - return directive; -} - -function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives, type: W['namedType'], ctors: Ctors) { - buildAppliedDirectives(definitionNode, type, ctors); - switch (definitionNode.kind) { - case 'ObjectTypeDefinition': - case 'InterfaceTypeDefinition': - const fieldBasedType = type as W['objectType'] | W['interfaceType']; - for (const fieldNode of definitionNode.fields ?? []) { - addField(buildFieldDefinition(fieldNode, fieldBasedType, ctors), fieldBasedType); - } - for (const itfNode of definitionNode.interfaces ?? []) { - const itfType = getReferencedType(type.schema()!, itfNode); - if (itfType.kind != 'InterfaceType') { - // TODO: check what error graphql-js thrown for this. - throw new GraphQLError(`Type ${fieldBasedType} cannot implement non-interface type ${itfType}`, [definitionNode, itfNode]); - } - addImplementedInterface(itfType, fieldBasedType); - } +function removeReferenceToType(referencer: SchemaElement, type: Type) { + switch (type.kind) { + case 'ListType': + removeReferenceToType(referencer, type.baseType()); break; - case 'UnionTypeDefinition': - const unionType = type as W['unionType']; - for (const namedType of definitionNode.types ?? []) { - addTypeToUnion(namedType.name.value, unionType); - } + case 'NonNullType': + removeReferenceToType(referencer, type.baseType()); break; - case 'EnumTypeDefinition': - throw new Error("TODO"); - case 'InputObjectTypeDefinition': - const inputObjectType = type as W['inputObjectType']; - for (const fieldNode of definitionNode.fields ?? []) { - addField(buildInputFieldDefinition(fieldNode, inputObjectType, ctors), inputObjectType); - } + default: + BaseNamedType.prototype['removeReferencer'].call(type, referencer); break; } } -function buildFieldDefinition(fieldNode: FieldDefinitionNode, parentType: W['objectType'] | W['interfaceType'], ctors: Ctors): W['fieldDefinition'] { - const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.schema()!, ctors) as W['outputType']; - const builtField = ctors.createFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); - buildAppliedDirectives(fieldNode, builtField, ctors); - for (const inputValueDef of fieldNode.arguments ?? []) { - addFieldArg(buildFieldArgumentDefinition(inputValueDef, builtField, ctors), builtField); +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); } - addReferencerToType(builtField, type); - return builtField; } -function buildWrapperTypeOrTypeRef(typeNode: TypeNode, schema: W['schema'], ctors: Ctors): W['type'] { - switch (typeNode.kind) { - case 'ListType': - return ctors.createList(buildWrapperTypeOrTypeRef(typeNode.type, schema, ctors)); - case 'NonNullType': - return ctors.createNonNull(buildWrapperTypeOrTypeRef(typeNode.type, schema, ctors)); - default: - return schema.type(typeNode.name.value)!; +function *typesToCopy(source: Schema, dest: Schema): Generator { + for (const type of source.builtInTypes.values()) { + if (!dest.builtInTypes.has(type.name)) { + yield type; + } } + yield* source.types.values(); } -function buildFieldArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['fieldDefinition'], ctors: Ctors): W['fieldArgumentDefinition'] { - const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.schema()!, ctors) as W['inputType']; - const built = ctors.createFieldArgumentDefinition(inputNode.name.value, parent, type, buildValue(inputNode.defaultValue), inputNode); - buildAppliedDirectives(inputNode, built, ctors); - addReferencerToType(built, type); - return built; -} - -function buildInputFieldDefinition(fieldNode: InputValueDefinitionNode, parentType: W['inputObjectType'], ctors: Ctors): W['inputFieldDefinition'] { - const type = buildWrapperTypeOrTypeRef(fieldNode.type, parentType.schema()!, ctors) as W['inputType']; - const builtField = ctors.createInputFieldDefinition(fieldNode.name.value, parentType, type, fieldNode); - buildAppliedDirectives(fieldNode, builtField, ctors); - addReferencerToType(builtField, type); - return builtField; -} - -function buildDirectiveDefinitionInner(directiveNode: DirectiveDefinitionNode, directive: W['directiveDefinition'], ctors: Ctors) { - for (const inputValueDef of directiveNode.arguments ?? []) { - addDirectiveArg(buildDirectiveArgumentDefinition(inputValueDef, directive, ctors), directive); +function *directivesToCopy(source: Schema, dest: Schema): Generator { + for (const directive of source.builtInDirectives.values()) { + if (!dest.builtInDirectives.has(directive.name)) { + yield directive; + } } - const locations = directiveNode.locations.map(({ value }) => value as DirectiveLocationEnum); - setDirectiveDefinitionRepeatableAndLocations(directive, directiveNode.repeatable, locations); -} - -function buildDirectiveArgumentDefinition(inputNode: InputValueDefinitionNode, parent: W['directiveDefinition'], ctors: Ctors): W['directiveArgumentDefinition'] { - const type = buildWrapperTypeOrTypeRef(inputNode.type, parent.schema()!, ctors) as W['inputType']; - const built = ctors.createDirectiveArgumentDefinition(inputNode.name.value, parent, type, buildValue(inputNode.defaultValue), inputNode); - buildAppliedDirectives(inputNode, built, ctors); - addReferencerToType(built, type); - return built; + yield* source.directives.values(); } -function copy(source: WS['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['schema'] { - const doc = destCtors.addSchemaDefinition(destCtors.schema(destBuiltIns), source.schemaDefinition.source); - for (const type of source.types.values()) { - addTypeDefinition(copyNamedTypeShallow(type, doc, destBuiltIns, destCtors), doc); - } - for (const directive of source.directives.values()) { - addDirectiveDefinition(copyDirectiveDefinition(directive, doc, destBuiltIns, destCtors), doc); - } - if (destBuiltIns != source.builtIns) { - // Any type/directive that is a built-in in the source but not the destination must be copied as a normal definition. - for (const builtInType of source.builtIns.builtInTypes()) { - if (!destBuiltIns.isBuiltInType(builtInType.name)) { - addTypeDefinition(copyNamedTypeShallow(builtInType, doc, destBuiltIns, destCtors), doc); - } - } - for (const builtInDirective of source.builtIns.builtInDirectives()) { - if (!destBuiltIns.isBuiltInDirective(builtInDirective.name)) { - addDirectiveDefinition(copyDirectiveDefinition(builtInDirective, doc, destBuiltIns, destCtors), doc); - } - } +function copy(source: Schema, dest: Schema) { + // We shallow copy types and directives (which we can actually fully copy directly) 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)); } - copySchemaDefinitionInner(source.schemaDefinition, doc.schemaDefinition, destCtors); - for (const type of source.types.values()) { - copyNamedTypeInner(type, doc.type(type.name)!, destCtors); + for (const directive of directivesToCopy(source, dest)) { + copyDirectiveDefinitionInner(directive, dest.addDirectiveDefinition(directive.name)); } - if (destBuiltIns != source.builtIns) { - for (const builtInType of source.builtIns.builtInTypes()) { - if (!destBuiltIns.isBuiltInType(builtInType.name)) { - copyNamedTypeInner(builtInType, doc.type(builtInType.name)!, destCtors); - } - } + copySchemaDefinitionInner(source.schemaDefinition, dest.schemaDefinition); + for (const type of typesToCopy(source, dest)) { + copyNamedTypeInner(type, dest.type(type.name)!); } - return doc; } -function copySchemaDefinitionInner(source: WS['schemaDefinition'], dest: WD['schemaDefinition'], destCtors: Ctors) { +function copySchemaDefinitionInner(source: SchemaDefinition, dest: SchemaDefinition) { for (const [root, type] of source.roots.entries()) { - addRoot(root, type.name, dest); + dest.setRoot(root, type.name); } - copyAppliedDirectives(source, dest, destCtors); + copyAppliedDirectives(source, dest); + dest.source = source.source; } -function copyAppliedDirectives(source: WS['schemaElement'], dest: WD['schemaElement'], destCtors: Ctors) { +function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement) { for (const directive of source.appliedDirectives) { - BaseElement.prototype['addAppliedDirective'].call(dest, copyDirective(directive, dest, destCtors)); - } -} - -function copyDirective(source: WS['directive'], parentDest: WD['schemaElement'], destCtors: Ctors): WD['directive'] { - const args = new Map(); - for (const [name, value] of source.arguments.entries()) { - args.set(name, value); + dest.applyDirective(directive.name, new Map(directive.arguments)); } - const directive = destCtors.createDirective(source.name, parentDest, args, source.source); - const definition = directive.definition; - if (!definition) { - throw new GraphQLError(`Unknown directive "@${directive.name}" applied to ${parentDest}.`); - } - addReferencerToDirectiveDefinition(directive, definition); - return directive; -} - -// Because types can refer to one another (through fields or directive applications), we first create a shallow copy of -// all types, and then copy fields (see below) assuming that the type "shell" exists. -function copyNamedTypeShallow(source: WS['namedType'], schema: WD['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['namedType'] { - return destCtors.createNamedType(source.kind, source.name, schema, destBuiltIns.isBuiltInType(source.name), source.source); } -function copyNamedTypeInner(source: WS['namedType'], dest: WD['namedType'], destCtors: Ctors) { - copyAppliedDirectives(source, dest, destCtors); +function copyNamedTypeInner(source: NamedType, dest: NamedType) { + copyAppliedDirectives(source, dest); + dest.source = source.source; switch (source.kind) { case 'ObjectType': + const destObjectType = dest as ObjectType; + for (const field of source.fields.values()) { + copyFieldDefinitionInner(field, destObjectType.addField(new FieldDefinition(field.name))); + } + for (const itf of source.interfaces) { + destObjectType.addImplementedInterface(itf.name); + } + break; case 'InterfaceType': - const sourceFieldBasedType = source as WS['objectType'] | WS['interfaceType']; - const destFieldBasedType = dest as WD['objectType'] | WD['interfaceType']; - for (const field of sourceFieldBasedType.fields.values()) { - addField(copyFieldDefinition(field, destFieldBasedType, destCtors), destFieldBasedType); + const destInterfaceType = dest as InterfaceType; + for (const field of source.fields.values()) { + copyFieldDefinitionInner(field, destInterfaceType.addField(new FieldDefinition(field.name))); } - for (const itf of sourceFieldBasedType.interfaces) { - addImplementedInterface(destFieldBasedType.schema()?.type(itf.name)! as WD['interfaceType'], destFieldBasedType); + for (const itf of source.interfaces) { + destInterfaceType.addImplementedInterface(itf.name); } break; case 'UnionType': - const sourceUnionType = source as WS['unionType']; - const destUnionType = dest as WD['unionType']; - for (const type of sourceUnionType.types) { - addTypeToUnion(type.name, destUnionType); + const destUnionType = dest as UnionType; + for (const type of source.types) { + destUnionType.addType(type.name); } break; + case 'EnumType': + const destEnumType = dest as EnumType; + for (const value of source.values) { + destEnumType.addValue(value.name); + } + break case 'InputObjectType': - const sourceInputObjectType = source as WS['inputObjectType']; - const destInputObjectType = dest as WD['inputObjectType']; - for (const field of sourceInputObjectType.fields.values()) { - addField(copyInputFieldDefinition(field, destInputObjectType, destCtors), destInputObjectType); + const destInputType = dest as InputObjectType; + for (const field of source.fields.values()) { + copyInputFieldDefinitionInner(field, destInputType.addField(new InputFieldDefinition(field.name))); } } } -function copyFieldDefinition(source: WS['fieldDefinition'], destParent: WD['objectType'] | WD['interfaceType'], destCtors: Ctors): WD['fieldDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['outputType']; - const copiedField = destCtors.createFieldDefinition(source.name, destParent, type, source.source); - copyAppliedDirectives(source, copiedField, destCtors); - for (const sourceArg of source.arguments.values()) { - addFieldArg(copyFieldArgumentDefinition(sourceArg, copiedField, destCtors), copiedField); +function copyFieldDefinitionInner

(source: FieldDefinition

, dest: FieldDefinition

) { + const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()!) as OutputType; + dest.type = type; + for (const arg of source.arguments.values()) { + const argType = copyWrapperTypeOrTypeRef(arg.type, dest.schema()!); + copyArgumentDefinitionInner(arg, dest.addArgument(arg.name, argType as InputType)); } - addReferencerToType(copiedField, type); - return copiedField; + copyAppliedDirectives(source, dest); + dest.source = source.source; } -function copyInputFieldDefinition(source: WS['inputFieldDefinition'], destParent: WD['inputObjectType'], destCtors: Ctors): WD['inputFieldDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['inputType']; - const copied = destCtors.createInputFieldDefinition(source.name, destParent, type, source.source); - copyAppliedDirectives(source, copied, destCtors); - addReferencerToType(copied, type); - return copied; +function copyInputFieldDefinitionInner(source: InputFieldDefinition, dest: InputFieldDefinition) { + const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()!) as InputType; + dest.type = type; + copyAppliedDirectives(source, dest); + dest.source = source.source; } -function copyWrapperTypeOrTypeRef(source: WS['type'] | WS['detached'], destParent: WD['schema'], destCtors: Ctors): WD['type'] | WD['detached'] { - if (source == undefined) { - return destCtors.checkDetached(undefined); +function copyWrapperTypeOrTypeRef(source: Type | undefined, destParent: Schema): Type | undefined { + if (!source) { + return undefined; } switch (source.kind) { case 'ListType': - return destCtors.createList(copyWrapperTypeOrTypeRef((source as WS['listType']).ofType, destParent, destCtors) as WD['type']); + return new ListType(copyWrapperTypeOrTypeRef(source.ofType, destParent)!); case 'NonNullType': - return destCtors.createNonNull(copyWrapperTypeOrTypeRef((source as WS['nonNullType']).ofType, destParent, destCtors) as WD['type']); + return new NonNullType(copyWrapperTypeOrTypeRef(source.ofType, destParent)! as NullableType); default: - return destParent.type((source as WS['namedType']).name)!; + return destParent.type(source.name)!; } } -function copyFieldArgumentDefinition(source: WS['fieldArgumentDefinition'], destParent: WD['fieldDefinition'], destCtors: Ctors): WD['fieldArgumentDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as WD['inputType']; - const copied = destCtors.createFieldArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source); - copyAppliedDirectives(source, copied, destCtors); - addReferencerToType(copied, type); - return copied; +function copyArgumentDefinitionInner

| DirectiveDefinition>(source: ArgumentDefinition

, dest: ArgumentDefinition

) { + const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()!) as InputType; + dest.type = type; + copyAppliedDirectives(source, dest); + dest.source = source.source; } -function copyDirectiveDefinition(source: WS['directiveDefinition'], destParent: WD['schema'], destBuiltIns: BuiltIns, destCtors: Ctors): WD['directiveDefinition'] { - const copiedDirective = destCtors.createDirectiveDefinition(source.name, destParent, destBuiltIns.isBuiltInDirective(source.name), source.source); - for (const sourceArg of source.arguments.values()) { - addDirectiveArg(copyDirectiveArgumentDefinition(sourceArg, copiedDirective, destCtors), copiedDirective); +function copyDirectiveDefinitionInner(source: DirectiveDefinition, dest: DirectiveDefinition) { + for (const arg of source.arguments.values()) { + const type = copyWrapperTypeOrTypeRef(arg.type, dest.schema()!); + copyArgumentDefinitionInner(arg, dest.addArgument(arg.name, type as InputType)); } - setDirectiveDefinitionRepeatableAndLocations(copiedDirective, source.repeatable, source.locations); - return copiedDirective; -} -function copyDirectiveArgumentDefinition(source: WS['directiveArgumentDefinition'], destParent: WD['directiveDefinition'], destCtors: Ctors): WD['directiveArgumentDefinition'] { - const type = copyWrapperTypeOrTypeRef(source.type, destParent.schema()!, destCtors) as InputType; - const copied = destCtors.createDirectiveArgumentDefinition(source.name, destParent, type, source.defaultValue(), source.source); - copyAppliedDirectives(source, copied, destCtors); - addReferencerToType(copied, type); - return copied; + dest.repeatable = source.repeatable; + dest.addLocations(...source.locations); } diff --git a/core-js/src/federation.ts b/core-js/src/federation.ts index 5838b7adb..5141aafd6 100644 --- a/core-js/src/federation.ts +++ b/core-js/src/federation.ts @@ -1,32 +1,35 @@ -import { BuiltIns } from "./definitions"; +import { BuiltIns, Schema } from "./definitions"; // TODO: Need a way to deal with the fact that the _Entity type is built after validation. export class FederationBuiltIns extends BuiltIns { - protected createBuiltInTypes(): void { - super.populateBuiltInTypes(); - this.addUnionType('_Entity'); - this.addObjectType('_Service').addField('sdl', this.getType('String')); - this.addScalarType('_Any'); + addBuiltInTypes(schema: Schema) { + super.addBuiltInTypes(schema); + + this.addBuiltInUnion(schema, '_Entity'); + this.addBuiltInObject(schema, '_Service').addField('sdl', schema.stringType()); + this.addBuiltInScalar(schema, '_Any'); } - protected populateBuiltInDirectives(): void { - this.addDirective('key') + addBuiltInDirectives(schema: Schema) { + super.addBuiltInDirectives(schema); + + this.addBuiltInDirective(schema, 'key') .addLocations('OBJECT', 'INTERFACE') - .addArgument('fields', this.getType('String')); + .addArgument('fields', schema.stringType()); - this.addDirective('extends') + this.addBuiltInDirective(schema, 'extends') .addLocations('OBJECT', 'INTERFACE'); - this.addDirective('external') + this.addBuiltInDirective(schema, 'external') .addLocations('OBJECT', 'FIELD_DEFINITION'); for (const name of ['requires', 'provides']) { - this.addDirective(name) + this.addBuiltInDirective(schema, name) .addLocations('FIELD_DEFINITION') - .addArgument('fields', this.getType('String')); + .addArgument('fields', schema.stringType()); } - this.addDirective('inaccessible') + this.addBuiltInDirective(schema, 'inaccessible') .addAllLocations(); } } diff --git a/core-js/src/print.ts b/core-js/src/print.ts index 01e42b8d9..b5b8bc5a5 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -1,23 +1,23 @@ import { - AnyDirective, - AnyDirectiveDefinition, - AnyFieldDefinition, - AnySchema, - AnyInputFieldDefinition, - AnyInputObjectType, - AnyNamedType, - AnyObjectType, - AnyScalarType, - AnySchemaDefinition, - AnySchemaElement, - AnyUnionType, defaultRootTypeName, - AnyInterfaceType + DirectiveDefinition, + EnumType, + FieldDefinition, + InputFieldDefinition, + InputObjectType, + InterfaceType, + NamedType, + ObjectType, + ScalarType, + Schema, + SchemaDefinition, + SchemaElement, + UnionType } from "./definitions"; const indent = " "; // Could be made an option at some point -export function printSchema(schema: AnySchema): string { +export function printSchema(schema: Schema): string { const directives = [...schema.directives.values()].filter(d => !d.isBuiltIn); const types = [...schema.types.values()] .filter(t => !t.isBuiltIn) @@ -33,7 +33,7 @@ export function printSchema(schema: AnySchema): string { ); } -function printSchemaDefinition(schemaDefinition: AnySchemaDefinition): string | undefined { +function printSchemaDefinition(schemaDefinition: SchemaDefinition): string | undefined { if (isSchemaOfCommonNames(schemaDefinition)) { return; } @@ -53,7 +53,7 @@ function printSchemaDefinition(schemaDefinition: AnySchemaDefinition): string | * * When using this naming convention, the schema description can be omitted. */ -function isSchemaOfCommonNames(schema: AnySchemaDefinition): boolean { +function isSchemaOfCommonNames(schema: SchemaDefinition): boolean { if (schema.appliedDirectives.length > 0) { return false; } @@ -65,17 +65,18 @@ function isSchemaOfCommonNames(schema: AnySchemaDefinition): boolean { return true; } -export function printTypeDefinition(type: AnyNamedType): string { +export function printTypeDefinition(type: NamedType): string { switch (type.kind) { case 'ScalarType': return printScalarType(type); - case 'ObjectType': return printObjectType(type); - case 'InterfaceType': return printInterfaceType(type); + case 'ObjectType': return printFieldBasedType('type', type); + case 'InterfaceType': return printFieldBasedType('interface', type); case 'UnionType': return printUnionType(type); + case 'EnumType': return printEnumType(type); case 'InputObjectType': return printInputObjectType(type); } } -export function printDirectiveDefinition(directive: AnyDirectiveDefinition): string { +export function printDirectiveDefinition(directive: DirectiveDefinition): string { const args = directive.arguments.size == 0 ? "" : [...directive.arguments.values()].map(arg => arg.toString()).join(', '); @@ -83,39 +84,44 @@ export function printDirectiveDefinition(directive: AnyDirectiveDefinition): str return `directive @${directive}${args}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } -function printAppliedDirectives(element: AnySchemaElement): string { +function printAppliedDirectives(element: SchemaElement): string { const appliedDirectives = element.appliedDirectives; - return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map((d: AnyDirective) => d.toString()).join(" "); + return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map(d => d.toString()).join(" "); } -function printScalarType(type: AnyScalarType): string { +function printScalarType(type: ScalarType): string { return `scalar ${type.name}${printAppliedDirectives(type)}` } -function printObjectType(type: AnyObjectType): string { - // TODO: missing interfaces - return `type ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +function printImplementedInterfaces(type: ObjectType | InterfaceType): string { + return type.interfaces.length + ? ' implements ' + type.interfaces.map(i => i.name).join(' & ') + : ''; } -function printInterfaceType(type: AnyInterfaceType): string { - // TODO: missing interfaces - return `interface ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +function printFieldBasedType(kind: string, type: ObjectType | InterfaceType): string { + return `${kind} ${type.name}${printImplementedInterfaces(type)}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); } -function printUnionType(type: AnyUnionType): string { +function printUnionType(type: UnionType): string { const possibleTypes = type.types.length ? ' = ' + type.types.join(' | ') : ''; return `union ${type}${possibleTypes}`; } -function printInputObjectType(type: AnyInputObjectType): string { +function printEnumType(type: EnumType): string { + const vals = type.values.map(v => `${v}${printAppliedDirectives(v)}`); + return `enum ${type}${printBlock(vals)}`; +} + +function printInputObjectType(type: InputObjectType): string { return `input ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); } -function printFields(fields: AnyFieldDefinition[] | AnyInputFieldDefinition[]): string { - return printBlock(fields.map((f: AnyFieldDefinition | AnyInputFieldDefinition) => indent + `${printField(f)}${printAppliedDirectives(f)}`)); +function printFields(fields: (FieldDefinition | InputFieldDefinition)[]): string { + return printBlock(fields.map(f => indent + `${printField(f)}${printAppliedDirectives(f)}`)); } -function printField(field: AnyFieldDefinition | AnyInputFieldDefinition): string { +function printField(field: FieldDefinition | InputFieldDefinition): string { let args = ''; if (field.kind == 'FieldDefinition' && field.arguments.size > 0) { args = '(' + [...field.arguments.values()].map(arg => `${arg}${printAppliedDirectives(arg)}`).join(', ') + ')'; diff --git a/package-lock.json b/package-lock.json index d19edfa99..2fb2bba81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@apollo/core": "file:core-js", "@apollo/federation": "file:federation-js", "@apollo/gateway": "file:gateway-js", "@apollo/harmonizer": "file:harmonizer", @@ -61,6 +62,20 @@ "npm": "7.x" } }, + "core-js": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@types/jest": "^26.0.23", + "deep-equal": "^2.0.5" + }, + "engines": { + "node": ">=12.13.0 <17.0" + }, + "peerDependencies": { + "graphql": "^14.5.0 || ^15.0.0" + } + }, "federation-integration-testsuite-js": { "name": "apollo-federation-integration-testsuite", "version": "0.25.1", @@ -149,6 +164,10 @@ "graphql": "^14.5.0 || ^15.0.0" } }, + "node_modules/@apollo/core": { + "resolved": "core-js", + "link": true + }, "node_modules/@apollo/federation": { "resolved": "federation-js", "link": true @@ -6068,7 +6087,6 @@ "version": "26.0.23", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", - "dev": true, "dependencies": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" @@ -9291,7 +9309,6 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", - "dev": true, "engines": { "node": ">= 10.14.2" } @@ -12508,7 +12525,6 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", - "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^26.6.2", @@ -12586,7 +12602,6 @@ "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", - "dev": true, "engines": { "node": ">= 10.14.2" } @@ -19932,6 +19947,13 @@ } }, "dependencies": { + "@apollo/core": { + "version": "file:core-js", + "requires": { + "@types/jest": "^26.0.23", + "deep-equal": "^2.0.5" + } + }, "@apollo/federation": { "version": "file:federation-js", "requires": { @@ -25258,7 +25280,6 @@ "version": "26.0.23", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", - "dev": true, "requires": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" @@ -27972,8 +27993,7 @@ "diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", - "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", - "dev": true + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==" }, "dir-glob": { "version": "3.0.1", @@ -30597,7 +30617,6 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", - "dev": true, "requires": { "chalk": "^4.0.0", "diff-sequences": "^26.6.2", @@ -30659,8 +30678,7 @@ "jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", - "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", - "dev": true + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==" }, "jest-haste-map": { "version": "26.6.2", From 66f368bfb4d10bb544e97932af42cbb802372194 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Thu, 17 Jun 2021 12:01:00 +0200 Subject: [PATCH 08/22] Add descriptions support (and a few minor fix/improvements) --- core-js/src/__tests__/definitions.test.ts | 67 ++++++++++++++++ core-js/src/buildSchema.ts | 18 ++++- core-js/src/definitions.ts | 62 +++++++-------- core-js/src/print.ts | 97 +++++++++++++++++++---- 4 files changed, 192 insertions(+), 52 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 822164571..471cc77d5 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -360,3 +360,70 @@ test('handling of enums', () => { expect(v2).toBeDefined(); expect(e.values).toEqual([v1, v2]); }); + +test('handling of descriptions', () => { +const sdl = +`"""A super schema full of great queries""" +schema { + query: ASetOfQueries +} + +"""Marks field that are deemed more important than others""" +directive @Important( + """The reason for the importance of this field""" + why: String = "because!" +) on FIELD_DEFINITION + +"""The actual queries of the schema""" +type ASetOfQueries { + """Returns a set of products""" + bestProducts: [Product!]! + + """Finds a product by ID""" + product( + """The ID identifying the product""" + id: ID! + ): Product +} + +"""A product that is also a book""" +type Book implements Product { + id: ID! + description: String! + + """ + Number of pages in the book. Good so the customer knows its buying a 1000 page book for instance + """ + pages: Int @Important +} + +type DVD implements Product { + id: ID! + description: String! + + """The film author""" + author: String @Important(why: "it's good to give credit!") +} + +"""Common interface to all our products""" +interface Product { + """Identifies the product""" + id: ID! + + """ + Something that explains what the product is. This can just be the title of the product, but this can be more than that if we want to. But it should be useful you know, otherwise our customer won't buy it. + """ + description: String! +}`; + const schema = buildSchema(sdl); + + // Checking we get back the schema through printing it is mostly good enough, but let's just + // make sure long descriptions don't get annoying formatting newlines for instance when acessed on the + // schema directly. + const longComment = "Something that explains what the product is. This can just be the title of the product, but this can be more than that if we want to. But it should be useful you know, otherwise our customer won't buy it."; + const product = schema.type('Product'); + expectInterfaceType(product); + expect(product.field('description')!.description).toBe(longComment); + + expect(printSchema(schema)).toBe(sdl); +}); diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index 5ab6b45f8..d3b1bc989 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -14,8 +14,10 @@ import { valueFromASTUntyped, ValueNode, NamedTypeNode, - ArgumentNode + ArgumentNode, + StringValueNode } from "graphql"; +import { Maybe } from "graphql/jsutils/Maybe"; import { BuiltIns, Schema, @@ -115,6 +117,7 @@ function buildNamedTypeAndDirectivesShallow(documentNode: DocumentNode, schema: } type NodeWithDirectives = {directives?: ReadonlyArray}; +type NodeWithDescription = {description?: Maybe}; type NodeWithArguments = {arguments?: ReadonlyArray}; function withoutTrailingDefinition(str: string): NamedTypeKind { @@ -130,11 +133,12 @@ function getReferencedType(node: NamedTypeNode, schema: Schema): NamedType { } function buildSchemaDefinitionInner(schemaNode: SchemaDefinitionNode, schemaDefinition: SchemaDefinition) { - schemaDefinition.source = schemaNode; - buildAppliedDirectives(schemaNode, schemaDefinition); for (const opTypeNode of schemaNode.operationTypes) { schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value, opTypeNode); } + schemaDefinition.source = schemaNode; + schemaDefinition.description = schemaNode.description?.value; + buildAppliedDirectives(schemaNode, schemaDefinition); } function buildAppliedDirectives(elementNode: NodeWithDirectives, element: SchemaElement) { @@ -151,7 +155,7 @@ function buildArgs(argumentsNode: NodeWithArguments): Map { return args; } -function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives, type: NamedType) { +function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives & NodeWithDescription, type: NamedType) { switch (definitionNode.kind) { case 'ObjectTypeDefinition': case 'InterfaceTypeDefinition': @@ -183,6 +187,7 @@ function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives break; } buildAppliedDirectives(definitionNode, type); + type.description = definitionNode.description?.value; type.source = definitionNode; } @@ -193,6 +198,7 @@ function buildFieldDefinitionInner(fieldNode: FieldDefinitionNode, field: FieldD buildArgumentDefinitionInner(inputValueDef, field.addArgument(inputValueDef.name.value)); } buildAppliedDirectives(fieldNode, field); + field.description = fieldNode.description?.value; field.source = fieldNode; } @@ -230,7 +236,9 @@ function buildWrapperTypeOrTypeRef(typeNode: TypeNode, schema: Schema): Type { function buildArgumentDefinitionInner(inputNode: InputValueDefinitionNode, arg: ArgumentDefinition) { const type = buildWrapperTypeOrTypeRef(inputNode.type, arg.schema()!); arg.type = ensureInputType(type, inputNode.type); + arg.defaultValue = buildValue(inputNode.defaultValue); buildAppliedDirectives(inputNode, arg); + arg.description = inputNode.description?.value; arg.source = inputNode; } @@ -238,6 +246,7 @@ function buildInputFieldDefinitionInner(fieldNode: InputValueDefinitionNode, fie const type = buildWrapperTypeOrTypeRef(fieldNode.type, field.schema()!); field.type = ensureInputType(type, fieldNode.type); buildAppliedDirectives(fieldNode, field); + field.description = fieldNode.description?.value; field.source = fieldNode; } @@ -248,5 +257,6 @@ function buildDirectiveDefinitionInner(directiveNode: DirectiveDefinitionNode, d directive.repeatable = directiveNode.repeatable; const locations = directiveNode.locations.map(({ value }) => value as DirectiveLocationEnum); directive.addLocations(...locations); + directive.description = directiveNode.description?.value; directive.source = directiveNode; } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index c2c1eea5d..378449f96 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -12,10 +12,6 @@ export type MutationRoot = 'mutation'; export type SubscriptionRoot = 'subscription'; export type SchemaRoot = QueryRoot | MutationRoot | SubscriptionRoot; -export function defaultRootTypeName(root: SchemaRoot) { - return root.charAt(0).toUpperCase() + root.slice(1); -} - export type Type = InputType | OutputType; export type NamedType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | InputObjectType; export type OutputType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | ListType | NonNullType; @@ -31,6 +27,10 @@ export type NullableType = NamedType | ListType; export type NamedTypeKind = NamedType['kind']; +export function defaultRootTypeName(root: SchemaRoot) { + return root.charAt(0).toUpperCase() + root.slice(1); +} + export function isNamedType(type: Type): type is NamedType { return type instanceof BaseNamedType; } @@ -92,6 +92,7 @@ export interface Named { export abstract class SchemaElement | Schema, Referencer> { protected _parent?: Parent; protected readonly _appliedDirectives: Directive[] = []; + description?: string; source?: ASTNode; abstract coordinate: string; @@ -895,17 +896,18 @@ export class FieldDefinition

extends BaseN addArgument(arg: ArgumentDefinition>): ArgumentDefinition>; addArgument(name: string, type?: InputType, defaultValue?: any): ArgumentDefinition>; addArgument(nameOrArg: string | ArgumentDefinition>, type?: InputType, defaultValue?: any): ArgumentDefinition> { - const toAdd = typeof nameOrArg === 'string' ? new ArgumentDefinition>(nameOrArg).setDefaultValue(defaultValue) : nameOrArg; + 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 buildError(`Argument ${toAdd.name} already exists on field ${this.name}`); } - if (toAdd.parent) { - // For convenience, let's not error out on adding an already added type. - if (toAdd.parent === this) { - return toAdd; - } - throw buildError(`Cannot add argument ${toAdd.name} to this instance of field ${this.name}; it is already attached to another schema`); - } this._args.set(toAdd.name, toAdd); SchemaElement.prototype['setParent'].call(toAdd, this); if (typeof nameOrArg === 'string') { @@ -974,7 +976,7 @@ export class InputFieldDefinition extends BaseNamedElementWithType | DirectiveDefinition> extends BaseNamedElementWithType { readonly kind: 'ArgumentDefinition' = 'ArgumentDefinition'; - private _defaultValue?: any + defaultValue?: any constructor(name: string) { super(name); @@ -985,15 +987,6 @@ export class ArgumentDefinition

| DirectiveDefini return `${parent == undefined ? '' : parent.coordinate}(${this.name}:)`; } - get defaultValue(): any { - return this._defaultValue; - } - - setDefaultValue(defaultValue: any): ArgumentDefinition

{ - this._defaultValue = defaultValue; - return this; - } - /** * Removes this argument definition from its parent element (field or directive). * @@ -1007,12 +1000,12 @@ export class ArgumentDefinition

| DirectiveDefini (this._parent.arguments as Map).delete(this.name); this._parent = undefined; this.type = undefined; - this._defaultValue = undefined; + this.defaultValue = undefined; return []; } toString() { - const defaultStr = this._defaultValue == undefined ? "" : ` = ${this._defaultValue}`; + const defaultStr = this.defaultValue == undefined ? "" : ` = ${valueToString(this.defaultValue)}`; return `${this.name}: ${this.type}${defaultStr}`; } } @@ -1078,17 +1071,18 @@ export class DirectiveDefinition extends BaseNamedElement { addArgument(arg: ArgumentDefinition): ArgumentDefinition; addArgument(name: string, type?: InputType, defaultValue?: any): ArgumentDefinition; addArgument(nameOrArg: string | ArgumentDefinition, type?: InputType, defaultValue?: any): ArgumentDefinition { - const toAdd = typeof nameOrArg === 'string' ? new ArgumentDefinition(nameOrArg).setDefaultValue(defaultValue) : nameOrArg; + 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 buildError(`Argument ${toAdd.name} already exists on field ${this.name}`); } - if (toAdd.parent) { - // For convenience, let's not error out on adding an already added type. - if (toAdd.parent === this) { - return toAdd; - } - throw buildError(`Cannot add argument ${toAdd.name} to this instance of field ${this.name}; it is already attached to another schema`); - } this._args.set(toAdd.name, toAdd); SchemaElement.prototype['setParent'].call(toAdd, this); if (typeof nameOrArg === 'string') { @@ -1162,6 +1156,10 @@ export class DirectiveDefinition extends BaseNamedElement { } } +// TODO: How do we deal with default values? It feels like it would make some sense to have `argument('x')` return the default +// value if `x` has one and wasn't explicitly set in the application. This would make code usage more pleasant. Should +// `arguments()` also return those though? Maybe have an option to both method to say if it should include them or not. +// (The question stands for matchArguments() as well though). export class Directive implements Named { private _parent?: SchemaElement; source?: ASTNode; diff --git a/core-js/src/print.ts b/core-js/src/print.ts index b5b8bc5a5..3ca248470 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -1,4 +1,5 @@ import { + ArgumentDefinition, defaultRootTypeName, DirectiveDefinition, EnumType, @@ -38,7 +39,7 @@ function printSchemaDefinition(schemaDefinition: SchemaDefinition): string | und return; } const rootEntries = [...schemaDefinition.roots.entries()].map(([root, type]) => `${indent}${root}: ${type}`); - return `schema${printAppliedDirectives(schemaDefinition)} {\n${rootEntries.join('\n')}\n}`; + return `${printDescription(schemaDefinition)}schema${printAppliedDirectives(schemaDefinition)} {\n${rootEntries.join('\n')}\n}`; } /** @@ -54,7 +55,7 @@ function printSchemaDefinition(schemaDefinition: SchemaDefinition): string | und * When using this naming convention, the schema description can be omitted. */ function isSchemaOfCommonNames(schema: SchemaDefinition): boolean { - if (schema.appliedDirectives.length > 0) { + if (schema.appliedDirectives.length > 0 || schema.description) { return false; } for (const [root, type] of schema.roots) { @@ -77,11 +78,8 @@ export function printTypeDefinition(type: NamedType): string { } export function printDirectiveDefinition(directive: DirectiveDefinition): string { - const args = directive.arguments.size == 0 - ? "" - : [...directive.arguments.values()].map(arg => arg.toString()).join(', '); const locations = directive.locations.join(' | '); - return `directive @${directive}${args}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; + return `${printDescription(directive)}directive @${directive}${printArgs([...directive.arguments.values()])}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } function printAppliedDirectives(element: SchemaElement): string { @@ -89,8 +87,25 @@ function printAppliedDirectives(element: SchemaElement): string { return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map(d => d.toString()).join(" "); } +function printDescription( + element: SchemaElement, + indentation: string = '', + firstInBlock: boolean = true +): string { + if (!element.description) { + return ''; + } + + const preferMultipleLines = element.description.length > 70; + const blockString = printBlockString(element.description, '', preferMultipleLines); + const prefix = + indentation && !firstInBlock ? '\n' + indentation : indentation; + + return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; +} + function printScalarType(type: ScalarType): string { - return `scalar ${type.name}${printAppliedDirectives(type)}` + return `${printDescription(type)}scalar ${type.name}${printAppliedDirectives(type)}` } function printImplementedInterfaces(type: ObjectType | InterfaceType): string { @@ -100,35 +115,85 @@ function printImplementedInterfaces(type: ObjectType | InterfaceType): string { } function printFieldBasedType(kind: string, type: ObjectType | InterfaceType): string { - return `${kind} ${type.name}${printImplementedInterfaces(type)}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); + return `${printDescription(type)}${kind} ${type.name}${printImplementedInterfaces(type)}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); } function printUnionType(type: UnionType): string { const possibleTypes = type.types.length ? ' = ' + type.types.join(' | ') : ''; - return `union ${type}${possibleTypes}`; + return `${printDescription(type)}union ${type}${printAppliedDirectives(type)}${possibleTypes}`; } function printEnumType(type: EnumType): string { const vals = type.values.map(v => `${v}${printAppliedDirectives(v)}`); - return `enum ${type}${printBlock(vals)}`; + return `${printDescription(type)}enum ${type}${printAppliedDirectives(type)}${printBlock(vals)}`; } function printInputObjectType(type: InputObjectType): string { - return `input ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); + return `${printDescription(type)}input ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); } function printFields(fields: (FieldDefinition | InputFieldDefinition)[]): string { - return printBlock(fields.map(f => indent + `${printField(f)}${printAppliedDirectives(f)}`)); + return printBlock(fields.map((f, i) => printDescription(f, indent, !i) + indent + `${printField(f)}${printAppliedDirectives(f)}`)); } function printField(field: FieldDefinition | InputFieldDefinition): string { - let args = ''; - if (field.kind == 'FieldDefinition' && field.arguments.size > 0) { - args = '(' + [...field.arguments.values()].map(arg => `${arg}${printAppliedDirectives(arg)}`).join(', ') + ')'; - } + let args = field.kind == 'FieldDefinition' ? printArgs([...field.arguments.values()], indent) : ''; return `${field.name}${args}: ${field.type}`; } +function printArgs(args: ArgumentDefinition[], indentation = '') { + if (args.length === 0) { + return ''; + } + + // If every arg does not have a description, print them on one line. + if (args.every(arg => !arg.description)) { + return '(' + args.map(printArg).join(', ') + ')'; + } + + const formattedArgs = args + .map((arg, i) => printDescription(arg, ' ' + indentation, !i) + ' ' + indentation + printArg(arg)) + .join('\n'); + return `(\n${formattedArgs}\n${indentation})`; +} + +function printArg(arg: ArgumentDefinition) { + return `${arg}${printAppliedDirectives(arg)}`; +} + function printBlock(items: string[]): string { return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; } + +/** + * Print a block string in the indented block form by adding a leading and + * trailing blank line. However, if a block string starts with whitespace and is + * a single-line, adding a leading blank line would strip that whitespace. + */ +function printBlockString( + value: string, + indentation: string = '', + preferMultipleLines: boolean = false, +): string { + const isSingleLine = value.indexOf('\n') === -1; + const hasLeadingSpace = value[0] === ' ' || value[0] === '\t'; + const hasTrailingQuote = value[value.length - 1] === '"'; + const hasTrailingSlash = value[value.length - 1] === '\\'; + const printAsMultipleLines = + !isSingleLine || + hasTrailingQuote || + hasTrailingSlash || + preferMultipleLines; + + let result = ''; + // Format a multi-line block quote to account for leading space. + if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) { + result += '\n' + indentation; + } + result += indentation ? value.replace(/\n/g, '\n' + indentation) : value; + if (printAsMultipleLines) { + result += '\n'; + } + + return '"""' + result.replace(/"""/g, '\\"""') + '"""'; +} From 79c8d24c0b402318c3f7b7ecf05f6de2830a40d3 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Thu, 17 Jun 2021 17:02:12 +0200 Subject: [PATCH 09/22] Handle some review remarks and minor cleanups --- core-js/src/__tests__/definitions.test.ts | 12 ++- core-js/src/buildSchema.ts | 2 +- core-js/src/definitions.ts | 107 +++++++++++----------- core-js/src/print.ts | 3 +- package-lock.json | 1 + 5 files changed, 66 insertions(+), 59 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 471cc77d5..f08f9c7d0 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -4,8 +4,8 @@ import { Type, DirectiveDefinition, InterfaceType, - SchemaElement, - EnumType + EnumType, + SchemaElement } from '../../dist/definitions'; import { printSchema } from '../../dist/print'; import { buildSchema } from '../../dist/buildSchema'; @@ -63,7 +63,7 @@ expect.extend({ }, toHaveDirective(element: SchemaElement, definition: DirectiveDefinition, args?: Map) { - const directives = element.appliedDirective(definition as any); + const directives = element.appliedDirectivesOf(definition); if (directives.length == 0) { return { message: () => `Cannot find directive @${definition} applied to element ${element} (whose applied directives are [${element.appliedDirectives.join(', ')}]`, @@ -224,7 +224,7 @@ test('removal of all inacessible elements of a schema', () => { `, federationBuiltIns); for (const element of schema.allSchemaElement()) { - if (element.appliedDirective(schema.directive('inaccessible')!).length > 0) { + if (element.appliedDirectivesOf(schema.directive('inaccessible')!).length > 0) { element.remove(); } } @@ -425,5 +425,9 @@ interface Product { expectInterfaceType(product); expect(product.field('description')!.description).toBe(longComment); + const directive = schema.directive('Important')!; + if (directive.repeatable === false) + product.appliedDirectivesOf(directive); + expect(printSchema(schema)).toBe(sdl); }); diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index d3b1bc989..125228cd6 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -63,7 +63,7 @@ export function buildSchema(source: string | Source, builtIns: BuiltIns = graphQ export function buildSchemaFromAST(documentNode: DocumentNode, builtIns: BuiltIns = graphQLBuiltIns): Schema { const schema = new Schema(builtIns); - // We do a first path to add all empty types and directives definition. This ensure any reference on one of + // We do a first pass to add all empty types and directives definition. This ensure any reference on one of // those can be resolved in the 2nd pass, regardless of the order of the definitions in the AST. buildNamedTypeAndDirectivesShallow(documentNode, schema); for (const definitionNode of documentNode.definitions) { diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 378449f96..56ed087ae 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -12,7 +12,7 @@ export type MutationRoot = 'mutation'; export type SubscriptionRoot = 'subscription'; export type SchemaRoot = QueryRoot | MutationRoot | SubscriptionRoot; -export type Type = InputType | OutputType; +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; @@ -89,8 +89,8 @@ export interface Named { readonly name: string; } -export abstract class SchemaElement | Schema, Referencer> { - protected _parent?: Parent; +export abstract class SchemaElement | Schema, TReferencer> { + protected _parent?: TParent; protected readonly _appliedDirectives: Directive[] = []; description?: string; source?: ASTNode; @@ -100,18 +100,22 @@ export abstract class SchemaElement | Sch schema(): Schema | undefined { if (!this._parent) { return undefined; - } else if ( this._parent instanceof Schema) { + } 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 { return (this._parent as SchemaElement).schema(); } } - get parent(): Parent | undefined { + get parent(): TParent | undefined { return this._parent; } - protected setParent(parent: Parent) { + protected setParent(parent: TParent) { assert(!this._parent, "Cannot set parent of a non-detached element"); this._parent = parent; } @@ -120,8 +124,11 @@ export abstract class SchemaElement | Sch return this._appliedDirectives; } - appliedDirective(definition: DirectiveDefinition): Directive[] { - return this._appliedDirectives.filter(d => d.name == definition.name); + appliedDirectivesOf(name: string): Directive[]; + appliedDirectivesOf(definition: DirectiveDefinition): Directive[]; + appliedDirectivesOf(nameOrDefinition: string | DirectiveDefinition): Directive[] { + const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; + return this._appliedDirectives.filter(d => d.name == directiveName); } applyDirective(directive: Directive): Directive; @@ -199,27 +206,27 @@ export abstract class SchemaElement | Sch } } - abstract remove(): Referencer[]; + abstract remove(): TReferencer[]; } -abstract class BaseNamedElement

| Schema, Referencer> extends SchemaElement implements Named { +abstract class BaseNamedElement | Schema, TReferencer> extends SchemaElement implements Named { constructor(readonly name: string) { super(); } } -abstract class BaseNamedType extends BaseNamedElement { - protected readonly _referencers: Set = new Set(); +abstract class BaseNamedType extends BaseNamedElement { + protected readonly _referencers: Set = new Set(); constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); } - private addReferencer(referencer: Referencer) { + private addReferencer(referencer: TReferencer) { this._referencers.add(referencer); } - private removeReferencer(referencer: Referencer) { + private removeReferencer(referencer: TReferencer) { this._referencers.delete(referencer); } @@ -227,7 +234,7 @@ abstract class BaseNamedType extends BaseNamedElement, void, undefined> { + *allChildElements(): Generator, void, undefined> { // Overriden by those types that do have chidrens } @@ -248,7 +255,7 @@ abstract class BaseNamedType extends BaseNamedElement extends BaseNamedElement | Schema, Referencer> extends BaseNamedElement { - private _type?: T; +abstract class BaseNamedElementWithType | Schema, Referencer> extends BaseNamedElement { + private _type?: TType; - get type(): T | undefined { + get type(): TType | undefined { return this._type; } - set type(type: T | undefined) { + set type(type: TType | undefined) { if (type) { this.checkUpdate(type); } else { @@ -471,7 +478,7 @@ export class Schema { yield this._schemaDefinition; for (const type of this.types.values()) { yield type; - yield* type.allChildrenElements(); + yield* type.allChildElements(); } for (const directive of this.directives.values()) { yield directive; @@ -657,7 +664,7 @@ abstract class FieldBasedType extends B return toAdd; } - *allChildrenElements(): Generator, void, undefined> { + *allChildElements(): Generator, void, undefined> { for (const field of this._fields.values()) { yield field; yield* field.arguments.values(); @@ -813,7 +820,7 @@ export class InputObjectType extends BaseNamedType { return toAdd; } - *allChildrenElements(): Generator, void, undefined> { + *allChildElements(): Generator, void, undefined> { yield* this._fields.values(); } @@ -832,13 +839,11 @@ export class InputObjectType extends BaseNamedType { } } -export class ListType { - readonly kind: 'ListType' = 'ListType'; - - constructor(protected _type: T) {} +class BaseWrapperType { + protected constructor(protected _type: T) {} - schema(): Schema { - return this.baseType().schema() as Schema; + schema(): Schema | undefined { + return this.baseType().schema(); } get ofType(): T { @@ -848,27 +853,25 @@ export class ListType { baseType(): NamedType { return isWrapperType(this._type) ? this._type.baseType() : this._type as NamedType; } +} + +export class ListType extends BaseWrapperType { + readonly kind: 'ListType' = 'ListType'; + + constructor(type: T) { + super(type); + } toString(): string { return `[${this.ofType}]`; } } -export class NonNullType { +export class NonNullType extends BaseWrapperType { readonly kind: 'NonNullType' = 'NonNullType'; - constructor(protected _type: T) {} - - schema(): Schema { - return this.baseType().schema() as Schema; - } - - get ofType(): T { - return this._type; - } - - baseType(): NamedType { - return isWrapperType(this._type) ? this._type.baseType() : this._type as NamedType; + constructor(type: T) { + super(type); } toString(): string { @@ -876,30 +879,30 @@ export class NonNullType { } } -export class FieldDefinition

extends BaseNamedElementWithType { +export class FieldDefinition extends BaseNamedElementWithType { readonly kind: 'FieldDefinition' = 'FieldDefinition'; - private readonly _args: Map>> = new Map(); + private readonly _args: Map>> = new Map(); get coordinate(): string { const parent = this.parent; return `${parent == undefined ? '' : parent.coordinate}.${this.name}`; } - get arguments(): ReadonlyMap>> { + get arguments(): ReadonlyMap>> { return this._args; } - argument(name: string): ArgumentDefinition> | undefined { + 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>; + 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 = new ArgumentDefinition>(nameOrArg); toAdd.defaultValue = defaultValue; } else { this.checkUpdate(nameOrArg); @@ -974,7 +977,7 @@ export class InputFieldDefinition extends BaseNamedElementWithType | DirectiveDefinition> extends BaseNamedElementWithType { +export class ArgumentDefinition | DirectiveDefinition> extends BaseNamedElementWithType { readonly kind: 'ArgumentDefinition' = 'ArgumentDefinition'; defaultValue?: any diff --git a/core-js/src/print.ts b/core-js/src/print.ts index 3ca248470..286b12881 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -19,9 +19,8 @@ import { const indent = " "; // Could be made an option at some point export function printSchema(schema: Schema): string { - const directives = [...schema.directives.values()].filter(d => !d.isBuiltIn); + const directives = [...schema.directives.values()]; const types = [...schema.types.values()] - .filter(t => !t.isBuiltIn) .sort((type1, type2) => type1.name.localeCompare(type2.name)); return ( [printSchemaDefinition(schema.schemaDefinition)] diff --git a/package-lock.json b/package-lock.json index 2fb2bba81..d8fec4f1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ } }, "core-js": { + "name": "@apollo/core", "version": "0.1.0", "license": "MIT", "dependencies": { From 11e49ad498137f226f9034b99e5c05ccd2ca72ce Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Thu, 17 Jun 2021 17:26:23 +0200 Subject: [PATCH 10/22] Switch directive application arguments to Record --- core-js/src/__tests__/definitions.test.ts | 10 +++---- core-js/src/buildSchema.ts | 6 ++--- core-js/src/definitions.ts | 32 +++++++++++++---------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index f08f9c7d0..41c78fa31 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -30,7 +30,7 @@ declare global { namespace jest { interface Matchers { toHaveField(name: string, type?: Type): R; - toHaveDirective(directive: DirectiveDefinition, args?: Map): R; + toHaveDirective(directive: DirectiveDefinition, args?: Record): R; } } } @@ -62,7 +62,7 @@ expect.extend({ } }, - toHaveDirective(element: SchemaElement, definition: DirectiveDefinition, args?: Map) { + toHaveDirective(element: SchemaElement, definition: DirectiveDefinition, args?: Record) { const directives = element.appliedDirectivesOf(definition); if (directives.length == 0) { return { @@ -81,7 +81,7 @@ expect.extend({ if (directive.matchArguments(args)) { return { // Not 100% certain that message is correct but I don't think it's going to be used ... - message: () => `Expected directive ${directive.name} applied to ${element} to have arguments ${args} but got ${directive.arguments}`, + message: () => `Expected directive ${directive.name} applied to ${element} to have arguments ${JSON.stringify(args)} but got ${JSON.stringify(directive.arguments)}`, pass: true }; } @@ -103,13 +103,13 @@ test('building a simple schema programatically', () => { queryType.addField('a', typeA); typeA.addField('q', queryType); typeA.applyDirective(inaccessible); - typeA.applyDirective(key, new Map([['fields', 'a']])); + typeA.applyDirective(key, { fields: 'a'}); expect(queryType).toBe(schema.schemaDefinition.root('query')); expect(queryType).toHaveField('a', typeA); expect(typeA).toHaveField('q', queryType); expect(typeA).toHaveDirective(inaccessible); - expect(typeA).toHaveDirective(key, new Map([['fields', 'a']])); + expect(typeA).toHaveDirective(key, { fields: 'a'}); }); diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index 125228cd6..b9601c092 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -147,10 +147,10 @@ function buildAppliedDirectives(elementNode: NodeWithDirectives, element: Schema } } -function buildArgs(argumentsNode: NodeWithArguments): Map { - const args = new Map(); +function buildArgs(argumentsNode: NodeWithArguments): Record { + const args = Object.create(null); for (const argNode of argumentsNode.arguments ?? []) { - args.set(argNode.name.value, buildValue(argNode.value)); + args[argNode.name.value] = buildValue(argNode.value); } return args; } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 56ed087ae..c6975d478 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -132,9 +132,9 @@ export abstract class SchemaElement | Sc } applyDirective(directive: Directive): Directive; - applyDirective(definition: DirectiveDefinition, args?: Map): Directive; - applyDirective(name: string, args?: Map, source?: ASTNode): Directive; - applyDirective(nameOrDefOrDirective: Directive | DirectiveDefinition | string, args?: Map, source?: ASTNode): Directive { + applyDirective(definition: DirectiveDefinition, args?: Record): Directive; + applyDirective(name: string, args?: Record, source?: ASTNode): Directive; + applyDirective(nameOrDefOrDirective: Directive | DirectiveDefinition | string, args?: Record, source?: ASTNode): Directive { let toAdd: Directive; if (nameOrDefOrDirective instanceof Directive) { this.checkUpdate(nameOrDefOrDirective); @@ -152,7 +152,7 @@ export abstract class SchemaElement | Sc this.checkUpdate(nameOrDefOrDirective); name = nameOrDefOrDirective.name; } - toAdd = new Directive(name, args ?? new Map()); + toAdd = new Directive(name, args ?? Object.create(null)); Directive.prototype['setParent'].call(toAdd, this); toAdd.source = source; } @@ -1167,7 +1167,7 @@ export class Directive implements Named { private _parent?: SchemaElement; source?: ASTNode; - constructor(readonly name: string, private _args: Map) {} + constructor(readonly name: string, private _args: Record) {} schema(): Schema | undefined { return this._parent?.schema(); @@ -1187,7 +1187,7 @@ export class Directive implements Named { return doc?.directive(this.name); } - get arguments() : ReadonlyMap { + get arguments() : Record { return this._args; } @@ -1195,14 +1195,17 @@ export class Directive implements Named { return this._args.get(name); } - matchArguments(expectedArgs: Map): boolean { - if (this._args.size !== expectedArgs.size) { + matchArguments(expectedArgs: Record): boolean { + const entries = Object.entries(this._args); + if (entries.length !== Object.keys(expectedArgs).length) { return false; } - for (var [key, val] of this._args) { - const expectedVal = expectedArgs.get(key); - // In cases of an undefined value, make sure the key actually exists on the object so there are no false positives - if (!valueEquals(expectedVal, val) || (expectedVal === undefined && !expectedArgs.has(key))) { + for (var [key, val] of entries) { + if (!(key in expectedArgs)) { + return false; + } + const expectedVal = expectedArgs[key]; + if (!valueEquals(expectedVal, val)) { return false; } } @@ -1227,7 +1230,8 @@ export class Directive implements Named { } toString(): string { - const args = this._args.size == 0 ? '' : '(' + [...this._args.entries()].map(([n, v]) => `${n}: ${valueToString(v)}`).join(', ') + ')'; + const entries = Object.entries(this._args); + const args = entries.length == 0 ? '' : '(' + entries.map(([n, v]) => `${n}: ${valueToString(v)}`).join(', ') + ')'; return `@${this.name}${args}`; } } @@ -1329,7 +1333,7 @@ function copySchemaDefinitionInner(source: SchemaDefinition, dest: SchemaDefinit function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement) { for (const directive of source.appliedDirectives) { - dest.applyDirective(directive.name, new Map(directive.arguments)); + dest.applyDirective(directive.name, { ...directive.arguments}); } } From c5fb16f2c40f2ba33c43b294c0c5fcbc29c363ed Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Thu, 17 Jun 2021 17:45:13 +0200 Subject: [PATCH 11/22] Make `Schema.types/directives` return generators rather than maps Since `Schema` exposes `Schema.type(name)`, `Schema.types` was only ever use to iterate on the type values and the fact it was a map was more getting in the way than anything. Having it return an iterable/generator avoid the cruft of always calling `values()` on the return. The patch also make those functions actual methods of `Schema` instead of getters, as having getters returning generators feels weird. This also also have an arg to those method to optionally include the built-ins (even if the default is still not too). --- core-js/src/definitions.ts | 57 +++++++++++++++++++++----------------- core-js/src/print.ts | 6 ++-- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index c6975d478..824da29c4 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -391,18 +391,20 @@ export class Schema { } /** - * A map of all the types defined on this schema _excluding_ the built-in types. + * All the types defined on this schema _excluding_ the built-in types, unless explicitly requested. */ - get types(): ReadonlyMap { - return this._types; + *types(includeBuiltIns: boolean = false): Generator { + if (includeBuiltIns) { + yield* this._builtInTypes.values(); + } + yield* this._types.values(); } /** - * A map of all the built-in types for this schema (those types that will not be displayed - * when printing the schema). + * All the built-in types for this schema (those that are not displayed when printing the schema). */ - get builtInTypes(): ReadonlyMap { - return this._builtInTypes; + builtInTypes(): IterableIterator { + return this._builtInTypes.values(); } /** @@ -457,16 +459,21 @@ export class Schema { return type; } - get directives(): ReadonlyMap { - return this._directives; + /** + * All the directive defined on this schema _excluding_ the built-in directives, unless explicitly requested. + */ + *directives(includeBuiltIns: boolean = false): Generator { + if (includeBuiltIns) { + yield* this._builtInDirectives.values(); + } + yield* this._directives.values(); } /** - * A map of all the built-in directives for this schema (those directives whose definition will not be displayed - * when printing the schema). + * All the built-in directives for this schema (those that are not displayed when printing the schema). */ - get builtInDirectives(): ReadonlyMap { - return this._builtInDirectives; + builtInDirectives(): IterableIterator { + return this._builtInDirectives.values(); } directive(name: string): DirectiveDefinition | undefined { @@ -476,13 +483,13 @@ export class Schema { *allSchemaElement(): Generator, void, undefined> { yield this._schemaDefinition; - for (const type of this.types.values()) { + for (const type of this.types()) { yield type; yield* type.allChildElements(); } - for (const directive of this.directives.values()) { + for (const directive of this.directives()) { yield directive; - yield* directive.arguments.values(); + yield* directive.arguments(); } } @@ -1063,8 +1070,8 @@ export class DirectiveDefinition extends BaseNamedElement { return `@{this.name}`; } - get arguments(): ReadonlyMap> { - return this._args; + arguments(): IterableIterator> { + return this._args.values(); } argument(name: string): ArgumentDefinition | undefined { @@ -1292,21 +1299,21 @@ export function newNamedType(kind: NamedTypeKind, name: string): NamedType { } function *typesToCopy(source: Schema, dest: Schema): Generator { - for (const type of source.builtInTypes.values()) { - if (!dest.builtInTypes.has(type.name)) { + for (const type of source.builtInTypes()) { + if (!dest.type(type.name)?.isBuiltIn) { yield type; } } - yield* source.types.values(); + yield* source.types(); } function *directivesToCopy(source: Schema, dest: Schema): Generator { - for (const directive of source.builtInDirectives.values()) { - if (!dest.builtInDirectives.has(directive.name)) { + for (const directive of source.builtInDirectives()) { + if (!dest.directive(directive.name)?.isBuiltIn) { yield directive; } } - yield* source.directives.values(); + yield* source.directives(); } function copy(source: Schema, dest: Schema) { @@ -1419,7 +1426,7 @@ function copyArgumentDefinitionInner

| DirectiveD } function copyDirectiveDefinitionInner(source: DirectiveDefinition, dest: DirectiveDefinition) { - for (const arg of source.arguments.values()) { + for (const arg of source.arguments()) { const type = copyWrapperTypeOrTypeRef(arg.type, dest.schema()!); copyArgumentDefinitionInner(arg, dest.addArgument(arg.name, type as InputType)); } diff --git a/core-js/src/print.ts b/core-js/src/print.ts index 286b12881..5be6534d5 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -19,8 +19,8 @@ import { const indent = " "; // Could be made an option at some point export function printSchema(schema: Schema): string { - const directives = [...schema.directives.values()]; - const types = [...schema.types.values()] + const directives = [...schema.directives()]; + const types = [...schema.types()] .sort((type1, type2) => type1.name.localeCompare(type2.name)); return ( [printSchemaDefinition(schema.schemaDefinition)] @@ -78,7 +78,7 @@ export function printTypeDefinition(type: NamedType): string { export function printDirectiveDefinition(directive: DirectiveDefinition): string { const locations = directive.locations.join(' | '); - return `${printDescription(directive)}directive @${directive}${printArgs([...directive.arguments.values()])}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; + return `${printDescription(directive)}directive @${directive}${printArgs([...directive.arguments()])}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } function printAppliedDirectives(element: SchemaElement): string { From e7b93db8803a53e1960efd99d27566d2ac5eec88 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Thu, 17 Jun 2021 17:58:01 +0200 Subject: [PATCH 12/22] Attempt at providing optional typechecking for directives --- core-js/src/__tests__/definitions.test.ts | 14 +++---- core-js/src/definitions.ts | 46 +++++++++++++++++------ core-js/src/federation.ts | 30 +++++++++++++-- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 41c78fa31..68a09e45d 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -30,7 +30,7 @@ declare global { namespace jest { interface Matchers { toHaveField(name: string, type?: Type): R; - toHaveDirective(directive: DirectiveDefinition, args?: Record): R; + toHaveDirective(directive: DirectiveDefinition, args?: TArgs): R; } } } @@ -97,8 +97,8 @@ test('building a simple schema programatically', () => { const schema = new Schema(federationBuiltIns); const queryType = schema.schemaDefinition.setRoot('query', schema.addType(new ObjectType('Query'))); const typeA = schema.addType(new ObjectType('A')); - const inaccessible = schema.directive('inaccessible')!; - const key = schema.directive('key')!; + const inaccessible = federationBuiltIns.inaccessibleDirective(schema); + const key = federationBuiltIns.keyDirective(schema); queryType.addField('a', typeA); typeA.addField('q', queryType); @@ -137,7 +137,7 @@ type MyQuery { expect(schema.schemaDefinition.root('query')).toBe(queryType); expect(queryType).toHaveField('a', typeA); const f2 = typeA.field('f2'); - expect(f2).toHaveDirective(schema.directive('inaccessible')!); + expect(f2).toHaveDirective(federationBuiltIns.inaccessibleDirective(schema)); expect(printSchema(schema)).toBe(sdl); expect(typeA).toHaveField('f1'); @@ -224,7 +224,7 @@ test('removal of all inacessible elements of a schema', () => { `, federationBuiltIns); for (const element of schema.allSchemaElement()) { - if (element.appliedDirectivesOf(schema.directive('inaccessible')!).length > 0) { + if (element.appliedDirectivesOf(federationBuiltIns.inaccessibleDirective(schema)).length > 0) { element.remove(); } } @@ -425,9 +425,5 @@ interface Product { expectInterfaceType(product); expect(product.field('description')!.description).toBe(longComment); - const directive = schema.directive('Important')!; - if (directive.repeatable === false) - product.appliedDirectivesOf(directive); - expect(printSchema(schema)).toBe(sdl); }); diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 824da29c4..97f60a5b0 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -125,7 +125,7 @@ export abstract class SchemaElement | Sc } appliedDirectivesOf(name: string): Directive[]; - appliedDirectivesOf(definition: DirectiveDefinition): Directive[]; + appliedDirectivesOf(definition: DirectiveDefinition): Directive[]; appliedDirectivesOf(nameOrDefinition: string | DirectiveDefinition): Directive[] { const directiveName = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name; return this._appliedDirectives.filter(d => d.name == directiveName); @@ -156,6 +156,7 @@ export abstract class SchemaElement | Sc Directive.prototype['setParent'].call(toAdd, this); toAdd.source = source; } + // TODO: we should typecheck arguments or our TApplicationArgs business is just a lie. this._appliedDirectives.push(toAdd); DirectiveDefinition.prototype['addReferencer'].call(toAdd.definition!, toAdd); return toAdd; @@ -351,6 +352,33 @@ export class BuiltIns { protected addBuiltInDirective(schema: Schema, name: string): DirectiveDefinition { return schema.addDirectiveDefinition(new DirectiveDefinition(name, true)); } + + protected getTypedDirective( + schema: Schema, + name: string + ): DirectiveDefinition { + const directive = schema.directive(name); + if (!directive) { + throw new Error(`The provided schema has not be built with the ${name} directive built-in`); + } + return directive as DirectiveDefinition; + } + + includeDirective(schema: Schema): DirectiveDefinition<{if: boolean}> { + return this.getTypedDirective(schema, 'include'); + } + + skipDirective(schema: Schema): DirectiveDefinition<{if: boolean}> { + return this.getTypedDirective(schema, 'skip'); + } + + deprecatedDirective(schema: Schema): DirectiveDefinition<{reason?: string}> { + return this.getTypedDirective(schema, 'deprecated'); + } + + specifiedByDirective(schema: Schema): DirectiveDefinition<{url: string}> { + return this.getTypedDirective(schema, 'specifiedBy'); + } } export class Schema { @@ -1054,13 +1082,13 @@ export class EnumValue extends BaseNamedElement { } } -export class DirectiveDefinition extends BaseNamedElement { +export class DirectiveDefinition extends BaseNamedElement { readonly kind: 'DirectiveDefinition' = 'DirectiveDefinition'; private readonly _args: Map> = new Map(); repeatable: boolean = false; private readonly _locations: DirectiveLocationEnum[] = []; - private readonly _referencers: Set = new Set(); + private readonly _referencers: Set> = new Set(); constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); @@ -1132,7 +1160,7 @@ export class DirectiveDefinition extends BaseNamedElement { return this; } - private addReferencer(referencer: Directive) { + private addReferencer(referencer: Directive) { assert(referencer, 'Referencer should exists'); this._referencers.add(referencer); } @@ -1170,11 +1198,11 @@ export class DirectiveDefinition extends BaseNamedElement { // value if `x` has one and wasn't explicitly set in the application. This would make code usage more pleasant. Should // `arguments()` also return those though? Maybe have an option to both method to say if it should include them or not. // (The question stands for matchArguments() as well though). -export class Directive implements Named { +export class Directive implements Named { private _parent?: SchemaElement; source?: ASTNode; - constructor(readonly name: string, private _args: Record) {} + constructor(readonly name: string, private _args: TArgs) {} schema(): Schema | undefined { return this._parent?.schema(); @@ -1194,14 +1222,10 @@ export class Directive implements Named { return doc?.directive(this.name); } - get arguments() : Record { + get arguments() : TArgs { return this._args; } - argument(name: string): any { - return this._args.get(name); - } - matchArguments(expectedArgs: Record): boolean { const entries = Object.entries(this._args); if (entries.length !== Object.keys(expectedArgs).length) { diff --git a/core-js/src/federation.ts b/core-js/src/federation.ts index 5141aafd6..b62d878ba 100644 --- a/core-js/src/federation.ts +++ b/core-js/src/federation.ts @@ -1,4 +1,4 @@ -import { BuiltIns, Schema } from "./definitions"; +import { BuiltIns, Schema, DirectiveDefinition, NonNullType } from "./definitions"; // TODO: Need a way to deal with the fact that the _Entity type is built after validation. export class FederationBuiltIns extends BuiltIns { @@ -15,7 +15,7 @@ export class FederationBuiltIns extends BuiltIns { this.addBuiltInDirective(schema, 'key') .addLocations('OBJECT', 'INTERFACE') - .addArgument('fields', schema.stringType()); + .addArgument('fields', new NonNullType(schema.stringType())); this.addBuiltInDirective(schema, 'extends') .addLocations('OBJECT', 'INTERFACE'); @@ -26,12 +26,36 @@ export class FederationBuiltIns extends BuiltIns { for (const name of ['requires', 'provides']) { this.addBuiltInDirective(schema, name) .addLocations('FIELD_DEFINITION') - .addArgument('fields', schema.stringType()); + .addArgument('fields', new NonNullType(schema.stringType())); } this.addBuiltInDirective(schema, 'inaccessible') .addAllLocations(); } + + keyDirective(schema: Schema): DirectiveDefinition<{fields: string}> { + return this.getTypedDirective(schema, 'key'); + } + + extendsDirective(schema: Schema): DirectiveDefinition<{}> { + return this.getTypedDirective(schema, 'extends'); + } + + externalDirective(schema: Schema): DirectiveDefinition<{}> { + return this.getTypedDirective(schema, 'external'); + } + + requiresDirective(schema: Schema): DirectiveDefinition<{fields: string}> { + return this.getTypedDirective(schema, 'requires'); + } + + providesDirective(schema: Schema): DirectiveDefinition<{fields: string}> { + return this.getTypedDirective(schema, 'provides'); + } + + inaccessibleDirective(schema: Schema): DirectiveDefinition<{}> { + return this.getTypedDirective(schema, 'inaccessible'); + } } export const federationBuiltIns = new FederationBuiltIns(); From 6913cf2a008a793b905e38fb15069650a9c8bd96 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Fri, 18 Jun 2021 12:00:36 +0200 Subject: [PATCH 13/22] Move coordinate() to NamedSchemaElement so SchemaDefinition don't have fake coordinates Also a few cleanups --- core-js/src/buildSchema.ts | 14 +-- core-js/src/definitions.ts | 172 ++++++++++++++++--------------------- core-js/src/print.ts | 4 +- 3 files changed, 82 insertions(+), 108 deletions(-) diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index b9601c092..8e1f6cfd2 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -136,12 +136,12 @@ function buildSchemaDefinitionInner(schemaNode: SchemaDefinitionNode, schemaDefi for (const opTypeNode of schemaNode.operationTypes) { schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value, opTypeNode); } - schemaDefinition.source = schemaNode; + schemaDefinition.sourceAST = schemaNode; schemaDefinition.description = schemaNode.description?.value; buildAppliedDirectives(schemaNode, schemaDefinition); } -function buildAppliedDirectives(elementNode: NodeWithDirectives, element: SchemaElement) { +function buildAppliedDirectives(elementNode: NodeWithDirectives, element: SchemaElement) { for (const directive of elementNode.directives ?? []) { element.applyDirective(directive.name.value, buildArgs(directive), directive) } @@ -188,7 +188,7 @@ function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives } buildAppliedDirectives(definitionNode, type); type.description = definitionNode.description?.value; - type.source = definitionNode; + type.sourceAST = definitionNode; } function buildFieldDefinitionInner(fieldNode: FieldDefinitionNode, field: FieldDefinition) { @@ -199,7 +199,7 @@ function buildFieldDefinitionInner(fieldNode: FieldDefinitionNode, field: FieldD } buildAppliedDirectives(fieldNode, field); field.description = fieldNode.description?.value; - field.source = fieldNode; + field.sourceAST = fieldNode; } export function ensureOutputType(type: Type, node: TypeNode): OutputType { @@ -239,7 +239,7 @@ function buildArgumentDefinitionInner(inputNode: InputValueDefinitionNode, arg: arg.defaultValue = buildValue(inputNode.defaultValue); buildAppliedDirectives(inputNode, arg); arg.description = inputNode.description?.value; - arg.source = inputNode; + arg.sourceAST = inputNode; } function buildInputFieldDefinitionInner(fieldNode: InputValueDefinitionNode, field: InputFieldDefinition) { @@ -247,7 +247,7 @@ function buildInputFieldDefinitionInner(fieldNode: InputValueDefinitionNode, fie field.type = ensureInputType(type, fieldNode.type); buildAppliedDirectives(fieldNode, field); field.description = fieldNode.description?.value; - field.source = fieldNode; + field.sourceAST = fieldNode; } function buildDirectiveDefinitionInner(directiveNode: DirectiveDefinitionNode, directive: DirectiveDefinition) { @@ -258,5 +258,5 @@ function buildDirectiveDefinitionInner(directiveNode: DirectiveDefinitionNode, d const locations = directiveNode.locations.map(({ value }) => value as DirectiveLocationEnum); directive.addLocations(...locations); directive.description = directiveNode.description?.value; - directive.source = directiveNode; + directive.sourceAST = directiveNode; } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 97f60a5b0..37b1b1550 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -89,13 +89,25 @@ export interface Named { readonly name: string; } -export abstract class SchemaElement | Schema, TReferencer> { +// Note 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> { protected _parent?: TParent; + sourceAST?: ASTNode; + + get parent(): TParent | undefined { + return this._parent; + } + + protected setParent(parent: TParent) { + assert(!this._parent, "Cannot set parent of a non-detached element"); + this._parent = parent; + } +} + +export abstract class SchemaElement | Schema> extends Element { protected readonly _appliedDirectives: Directive[] = []; description?: string; - source?: ASTNode; - - abstract coordinate: string; schema(): Schema | undefined { if (!this._parent) { @@ -107,19 +119,10 @@ export abstract class SchemaElement | Sc // 'A & B' should always be assignable to both 'A' and 'B'). return this._parent as any; } else { - return (this._parent as SchemaElement).schema(); + return (this._parent as SchemaElement).schema(); } } - get parent(): TParent | undefined { - return this._parent; - } - - protected setParent(parent: TParent) { - assert(!this._parent, "Cannot set parent of a non-detached element"); - this._parent = parent; - } - get appliedDirectives(): readonly Directive[] { return this._appliedDirectives; } @@ -153,8 +156,8 @@ export abstract class SchemaElement | Sc name = nameOrDefOrDirective.name; } toAdd = new Directive(name, args ?? Object.create(null)); - Directive.prototype['setParent'].call(toAdd, this); - toAdd.source = source; + Element.prototype['setParent'].call(toAdd, this); + toAdd.sourceAST = source; } // TODO: we should typecheck arguments or our TApplicationArgs business is just a lie. this._appliedDirectives.push(toAdd); @@ -206,17 +209,19 @@ export abstract class SchemaElement | Sc } } } - - abstract remove(): TReferencer[]; } -abstract class BaseNamedElement | Schema, TReferencer> extends SchemaElement implements Named { +export abstract class NamedSchemaElement | Schema, TReferencer> extends SchemaElement implements Named { constructor(readonly name: string) { super(); } + + abstract coordinate: string; + + abstract remove(): TReferencer[]; } -abstract class BaseNamedType extends BaseNamedElement { +abstract class BaseNamedType extends NamedSchemaElement { protected readonly _referencers: Set = new Set(); constructor(name: string, readonly isBuiltIn: boolean = false) { @@ -235,7 +240,7 @@ abstract class BaseNamedType extends BaseNamedElement, void, undefined> { + *allChildElements(): Generator, void, undefined> { // Overriden by those types that do have chidrens } @@ -265,7 +270,7 @@ abstract class BaseNamedType extends BaseNamedElement { SchemaElement.prototype['removeTypeReferenceInternal'].call(r, this); @@ -282,7 +287,7 @@ abstract class BaseNamedType extends BaseNamedElement | Schema, Referencer> extends BaseNamedElement { +abstract class BaseNamedElementWithType | Schema, Referencer> extends NamedSchemaElement { private _type?: TType; get type(): TType | undefined { @@ -391,7 +396,7 @@ export class Schema { constructor(private readonly builtIns: BuiltIns = graphQLBuiltIns) { this._schemaDefinition = new SchemaDefinition(); - SchemaElement.prototype['setParent'].call(this._schemaDefinition, this); + Element.prototype['setParent'].call(this._schemaDefinition, this); builtIns.addBuiltInTypes(this); builtIns.addBuiltInDirectives(this); this.isConstructed = true; @@ -413,11 +418,6 @@ export class Schema { return this._schemaDefinition; } - private resetSchemaDefinition() { - this._schemaDefinition = new SchemaDefinition(); - SchemaElement.prototype['setParent'].call(this._schemaDefinition, this); - } - /** * All the types defined on this schema _excluding_ the built-in types, unless explicitly requested. */ @@ -483,7 +483,7 @@ export class Schema { } else { this._types.set(type.name, type); } - SchemaElement.prototype['setParent'].call(type, this); + Element.prototype['setParent'].call(type, this); return type; } @@ -509,8 +509,7 @@ export class Schema { return directive ? directive : this._builtInDirectives.get(name); } - *allSchemaElement(): Generator, void, undefined> { - yield this._schemaDefinition; + *allNamedSchemaElement(): Generator, void, undefined> { for (const type of this.types()) { yield type; yield* type.allChildElements(); @@ -521,6 +520,11 @@ export class Schema { } } + *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; @@ -543,7 +547,7 @@ export class Schema { } else { this._directives.set(definition.name, definition); } - SchemaElement.prototype['setParent'].call(definition, this); + Element.prototype['setParent'].call(definition, this); return definition; } @@ -554,14 +558,10 @@ export class Schema { } } -export class SchemaDefinition extends SchemaElement { - readonly kind: 'SchemaDefinition' = 'SchemaDefinition'; +export class SchemaDefinition extends SchemaElement { + readonly kind = 'SchemaDefinition' as const; protected readonly _roots: Map = new Map(); - get coordinate(): string { - return ''; - } - get roots(): ReadonlyMap { return this._roots; } @@ -588,7 +588,7 @@ export class SchemaDefinition extends SchemaElement { toSet = nameOrType; } this._roots.set(rootType, toSet); - this.source = source; + this.sourceAST = source; addReferenceToType(this, toSet); return toSet; } @@ -601,29 +601,13 @@ export class SchemaDefinition extends SchemaElement { } } - remove(): never[] { - if (!this._parent) { - return []; - } - // We don't want to leave the schema without a SchemaDefinition, so we create an empty one. Note that since we essentially - // clear this one so we could leave it (one exception is the source which we don't bother cleaning). But it feels - // more consistent not to, so that a schemaElement is consistently always detached after a remove()). - Schema.prototype['resetSchemaDefinition'].call(this._parent); - this._parent = undefined; - for (const directive of this._appliedDirectives) { - directive.remove(); - } - // There can be no other referencers than the parent schema. - return []; - } - toString() { return `schema[${[...this._roots.keys()].join(', ')}]`; } } export class ScalarType extends BaseNamedType { - readonly kind: 'ScalarType' = 'ScalarType'; + readonly kind = 'ScalarType' as const; protected removeTypeReference(type: NamedType) { assert(false, `Scalar type ${this} can't reference other types; shouldn't be asked to remove reference to ${type}`); @@ -691,7 +675,7 @@ abstract class FieldBasedType extends B throw buildError(`Field ${toAdd.name} already exists on ${this}`); } this._fields.set(toAdd.name, toAdd); - SchemaElement.prototype['setParent'].call(toAdd, this); + 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') { toAdd.type = type; @@ -699,7 +683,7 @@ abstract class FieldBasedType extends B return toAdd; } - *allChildElements(): Generator, void, undefined> { + *allChildElements(): Generator, void, undefined> { for (const field of this._fields.values()) { yield field; yield* field.arguments.values(); @@ -721,11 +705,11 @@ abstract class FieldBasedType extends B } export class ObjectType extends FieldBasedType { - readonly kind: 'ObjectType' = 'ObjectType'; + readonly kind = 'ObjectType' as const; } export class InterfaceType extends FieldBasedType { - readonly kind: 'InterfaceType' = 'InterfaceType'; + readonly kind = 'InterfaceType' as const; allImplementations(): (ObjectType | InterfaceType)[] { return [...this._referencers].filter(ref => ref.kind === 'ObjectType' || ref.kind === 'InterfaceType') as (ObjectType | InterfaceType)[]; @@ -738,7 +722,7 @@ export class InterfaceType extends FieldBasedType { - readonly kind: 'UnionType' = 'UnionType'; + readonly kind = 'UnionType' as const; protected readonly _types: ObjectType[] = []; get types(): readonly ObjectType[] { @@ -782,7 +766,7 @@ export class UnionType extends BaseNamedType { } export class EnumType extends BaseNamedType { - readonly kind: 'EnumType' = 'EnumType'; + readonly kind = 'EnumType' as const; protected readonly _values: EnumValue[] = []; get values(): readonly EnumValue[] { @@ -827,7 +811,7 @@ export class EnumType extends BaseNamedType { } export class InputObjectType extends BaseNamedType { - readonly kind: 'InputObjectType' = 'InputObjectType'; + readonly kind = 'InputObjectType' as const; private readonly _fields: Map = new Map(); get fields(): ReadonlyMap { @@ -847,7 +831,7 @@ export class InputObjectType extends BaseNamedType { throw buildError(`Field ${toAdd.name} already exists on ${this}`); } this._fields.set(toAdd.name, toAdd); - SchemaElement.prototype['setParent'].call(toAdd, this); + 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') { toAdd.type = type; @@ -855,7 +839,7 @@ export class InputObjectType extends BaseNamedType { return toAdd; } - *allChildElements(): Generator, void, undefined> { + *allChildElements(): Generator, void, undefined> { yield* this._fields.values(); } @@ -891,7 +875,7 @@ class BaseWrapperType { } export class ListType extends BaseWrapperType { - readonly kind: 'ListType' = 'ListType'; + readonly kind = 'ListType' as const; constructor(type: T) { super(type); @@ -903,7 +887,7 @@ export class ListType extends BaseWrapperType { } export class NonNullType extends BaseWrapperType { - readonly kind: 'NonNullType' = 'NonNullType'; + readonly kind = 'NonNullType' as const; constructor(type: T) { super(type); @@ -915,7 +899,7 @@ export class NonNullType extends BaseWrapperType { } export class FieldDefinition extends BaseNamedElementWithType { - readonly kind: 'FieldDefinition' = 'FieldDefinition'; + readonly kind = 'FieldDefinition' as const; private readonly _args: Map>> = new Map(); get coordinate(): string { @@ -947,7 +931,7 @@ export class FieldDefinition extends throw buildError(`Argument ${toAdd.name} already exists on field ${this.name}`); } this._args.set(toAdd.name, toAdd); - SchemaElement.prototype['setParent'].call(toAdd, this); + Element.prototype['setParent'].call(toAdd, this); if (typeof nameOrArg === 'string') { toAdd.type = type; } @@ -983,7 +967,7 @@ export class FieldDefinition extends } export class InputFieldDefinition extends BaseNamedElementWithType { - readonly kind: 'InputFieldDefinition' = 'InputFieldDefinition'; + readonly kind = 'InputFieldDefinition' as const; get coordinate(): string { const parent = this.parent; @@ -1013,7 +997,7 @@ export class InputFieldDefinition extends BaseNamedElementWithType | DirectiveDefinition> extends BaseNamedElementWithType { - readonly kind: 'ArgumentDefinition' = 'ArgumentDefinition'; + readonly kind = 'ArgumentDefinition' as const; defaultValue?: any constructor(name: string) { @@ -1048,8 +1032,8 @@ export class ArgumentDefinition | Directive } } -export class EnumValue extends BaseNamedElement { - readonly kind: 'EnumValue' = 'EnumValue'; +export class EnumValue extends NamedSchemaElement { + readonly kind = 'EnumValue' as const; get coordinate(): string { const parent = this.parent; @@ -1082,8 +1066,8 @@ export class EnumValue extends BaseNamedElement { } } -export class DirectiveDefinition extends BaseNamedElement { - readonly kind: 'DirectiveDefinition' = 'DirectiveDefinition'; +export class DirectiveDefinition extends NamedSchemaElement { + readonly kind = 'DirectiveDefinition' as const; private readonly _args: Map> = new Map(); repeatable: boolean = false; @@ -1122,7 +1106,7 @@ export class DirectiveDefinition implements Named { - private _parent?: SchemaElement; - source?: ASTNode; - - constructor(readonly name: string, private _args: TArgs) {} +export class Directive extends Element> implements Named { + constructor(readonly name: string, private _args: TArgs) { + super(); + } schema(): Schema | undefined { return this._parent?.schema(); } - get parent(): SchemaElement | undefined { - return this._parent; - } - - private setParent(parent: SchemaElement) { - assert(!this._parent, "Cannot set parent of a non-detached directive"); - this._parent = parent; - } - get definition(): DirectiveDefinition | undefined { const doc = this.schema(); return doc?.directive(this.name); @@ -1277,7 +1251,7 @@ function valueEquals(a: any, b: any): boolean { return deepEqual(a, b); } -function addReferenceToType(referencer: SchemaElement, type: Type) { +function addReferenceToType(referencer: SchemaElement, type: Type) { switch (type.kind) { case 'ListType': addReferenceToType(referencer, type.baseType()); @@ -1291,7 +1265,7 @@ function addReferenceToType(referencer: SchemaElement, type: Type) { } } -function removeReferenceToType(referencer: SchemaElement, type: Type) { +function removeReferenceToType(referencer: SchemaElement, type: Type) { switch (type.kind) { case 'ListType': removeReferenceToType(referencer, type.baseType()); @@ -1359,10 +1333,10 @@ function copySchemaDefinitionInner(source: SchemaDefinition, dest: SchemaDefinit dest.setRoot(root, type.name); } copyAppliedDirectives(source, dest); - dest.source = source.source; + dest.sourceAST = source.sourceAST; } -function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement) { +function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement) { for (const directive of source.appliedDirectives) { dest.applyDirective(directive.name, { ...directive.arguments}); } @@ -1370,7 +1344,7 @@ function copyAppliedDirectives(source: SchemaElement, dest: SchemaElem function copyNamedTypeInner(source: NamedType, dest: NamedType) { copyAppliedDirectives(source, dest); - dest.source = source.source; + dest.sourceAST = source.sourceAST; switch (source.kind) { case 'ObjectType': const destObjectType = dest as ObjectType; @@ -1418,14 +1392,14 @@ function copyFieldDefinitionInner

(source: copyArgumentDefinitionInner(arg, dest.addArgument(arg.name, argType as InputType)); } copyAppliedDirectives(source, dest); - dest.source = source.source; + dest.sourceAST = source.sourceAST; } function copyInputFieldDefinitionInner(source: InputFieldDefinition, dest: InputFieldDefinition) { const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()!) as InputType; dest.type = type; copyAppliedDirectives(source, dest); - dest.source = source.source; + dest.sourceAST = source.sourceAST; } function copyWrapperTypeOrTypeRef(source: Type | undefined, destParent: Schema): Type | undefined { @@ -1446,7 +1420,7 @@ function copyArgumentDefinitionInner

| DirectiveD const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()!) as InputType; dest.type = type; copyAppliedDirectives(source, dest); - dest.source = source.source; + dest.sourceAST = source.sourceAST; } function copyDirectiveDefinitionInner(source: DirectiveDefinition, dest: DirectiveDefinition) { diff --git a/core-js/src/print.ts b/core-js/src/print.ts index 5be6534d5..d9d023668 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -81,13 +81,13 @@ export function printDirectiveDefinition(directive: DirectiveDefinition): string return `${printDescription(directive)}directive @${directive}${printArgs([...directive.arguments()])}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } -function printAppliedDirectives(element: SchemaElement): string { +function printAppliedDirectives(element: SchemaElement): string { const appliedDirectives = element.appliedDirectives; return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map(d => d.toString()).join(" "); } function printDescription( - element: SchemaElement, + element: SchemaElement, indentation: string = '', firstInBlock: boolean = true ): string { From dac453bfede329761da52d2314ebd08be7fe8677 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Fri, 18 Jun 2021 13:46:45 +0200 Subject: [PATCH 14/22] Fix handling of references within wrapper types --- core-js/src/definitions.ts | 53 ++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 37b1b1550..7e9052609 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -40,10 +40,7 @@ export function isWrapperType(type: Type): type is WrapperType { } export function isOutputType(type: Type): type is OutputType { - if (isWrapperType(type)) { - return isOutputType(type.baseType()); - } - switch (type.kind) { + switch (baseType(type).kind) { case 'ScalarType': case 'ObjectType': case 'UnionType': @@ -64,10 +61,7 @@ export function ensureOutputType(type: Type): OutputType { } export function isInputType(type: Type): type is InputType { - if (isWrapperType(type)) { - return isInputType(type.baseType()); - } - switch (type.kind) { + switch (baseType(type).kind) { case 'ScalarType': case 'EnumType': case 'InputObjectType': @@ -85,6 +79,10 @@ export function ensureInputType(type: Type): InputType { } } +export function baseType(type: Type): NamedType { + return isWrapperType(type) ? type.baseType() : type; +} + export interface Named { readonly name: string; } @@ -95,20 +93,6 @@ abstract class Element | Schema> { protected _parent?: TParent; sourceAST?: ASTNode; - get parent(): TParent | undefined { - return this._parent; - } - - protected setParent(parent: TParent) { - assert(!this._parent, "Cannot set parent of a non-detached element"); - this._parent = parent; - } -} - -export abstract class SchemaElement | Schema> extends Element { - protected readonly _appliedDirectives: Directive[] = []; - description?: string; - schema(): Schema | undefined { if (!this._parent) { return undefined; @@ -123,6 +107,20 @@ export abstract class SchemaElement | Schema> } } + get parent(): TParent | undefined { + return this._parent; + } + + protected setParent(parent: TParent) { + assert(!this._parent, "Cannot set parent of a non-detached element"); + this._parent = parent; + } +} + +export abstract class SchemaElement | Schema> extends Element { + protected readonly _appliedDirectives: Directive[] = []; + description?: string; + get appliedDirectives(): readonly Directive[] { return this._appliedDirectives; } @@ -310,9 +308,9 @@ abstract class BaseNamedElementWithType extends B return this._interfaces.some(i => i.name == name); } - addImplementedInterface(itf: InterfaceType): InterfaceType; - addImplementedInterface(name: string, source?: ASTNode): InterfaceType; addImplementedInterface(nameOrItf: InterfaceType | string, source?: ASTNode): InterfaceType { let toAdd: InterfaceType; if (typeof nameOrItf === 'string') { @@ -698,6 +694,7 @@ abstract class FieldBasedType extends B } protected removeInnerElements(): void { + this._interfaces.splice(0, this._interfaces.length); for (const field of this._fields.values()) { field.remove(); } @@ -870,7 +867,7 @@ class BaseWrapperType { } baseType(): NamedType { - return isWrapperType(this._type) ? this._type.baseType() : this._type as NamedType; + return baseType(this._type); } } From e9120a73bd008a2adfe6484f384dbf9b15f4ab69 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Mon, 21 Jun 2021 12:03:03 +0200 Subject: [PATCH 15/22] Minor cleanups --- core-js/src/buildSchema.ts | 35 ++++++++++++++++++---- core-js/src/definitions.ts | 61 +++++++++++++++++++++----------------- 2 files changed, 63 insertions(+), 33 deletions(-) diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index 8e1f6cfd2..072540128 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -15,7 +15,8 @@ import { ValueNode, NamedTypeNode, ArgumentNode, - StringValueNode + StringValueNode, + ASTNode } from "graphql"; import { Maybe } from "graphql/jsutils/Maybe"; import { @@ -132,9 +133,30 @@ function getReferencedType(node: NamedTypeNode, schema: Schema): NamedType { return type; } +function withNodeAttachedToError(operation: () => void, node: ASTNode) { + try { + operation(); + } catch (e) { + if (e instanceof GraphQLError) { + const allNodes: ASTNode | ASTNode[] = e.nodes ? [node, ...e.nodes] : node; + throw new GraphQLError( + e.message, + allNodes, + e.source, + e.positions, + e.path, + e.originalError, + e.extensions + ); + } else { + throw e; + } + } +} + function buildSchemaDefinitionInner(schemaNode: SchemaDefinitionNode, schemaDefinition: SchemaDefinition) { for (const opTypeNode of schemaNode.operationTypes) { - schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value, opTypeNode); + withNodeAttachedToError(() => schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value), opTypeNode); } schemaDefinition.sourceAST = schemaNode; schemaDefinition.description = schemaNode.description?.value; @@ -143,7 +165,10 @@ function buildSchemaDefinitionInner(schemaNode: SchemaDefinitionNode, schemaDefi function buildAppliedDirectives(elementNode: NodeWithDirectives, element: SchemaElement) { for (const directive of elementNode.directives ?? []) { - element.applyDirective(directive.name.value, buildArgs(directive), directive) + withNodeAttachedToError(() => { + const d = element.applyDirective(directive.name.value, buildArgs(directive)); + d.sourceAST = directive; + }, directive); } } @@ -164,13 +189,13 @@ function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives buildFieldDefinitionInner(fieldNode, fieldBasedType.addField(fieldNode.name.value)); } for (const itfNode of definitionNode.interfaces ?? []) { - fieldBasedType.addImplementedInterface(itfNode.name.value, itfNode); + withNodeAttachedToError(() => fieldBasedType.addImplementedInterface(itfNode.name.value), itfNode); } break; case 'UnionTypeDefinition': const unionType = type as UnionType; for (const namedType of definitionNode.types ?? []) { - unionType.addType(namedType.name.value, namedType); + withNodeAttachedToError(() => unionType.addType(namedType.name.value), namedType); } break; case 'EnumTypeDefinition': diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 7e9052609..2984584d0 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -132,30 +132,32 @@ export abstract class SchemaElement | Schema> return this._appliedDirectives.filter(d => d.name == directiveName); } - applyDirective(directive: Directive): Directive; - applyDirective(definition: DirectiveDefinition, args?: Record): Directive; - applyDirective(name: string, args?: Record, source?: ASTNode): Directive; - applyDirective(nameOrDefOrDirective: Directive | DirectiveDefinition | string, args?: Record, source?: ASTNode): Directive { - let toAdd: Directive; + applyDirective( + nameOrDefOrDirective: Directive | DirectiveDefinition | string, + args?: TApplicationArgs + ): Directive { + let toAdd: Directive; if (nameOrDefOrDirective instanceof Directive) { this.checkUpdate(nameOrDefOrDirective); toAdd = nameOrDefOrDirective; + if (args) { + toAdd.setArguments(args); + } } else { let name: string; if (typeof nameOrDefOrDirective === 'string') { this.checkUpdate(); const def = this.schema()!.directive(nameOrDefOrDirective); if (!def) { - throw new GraphQLError(`Cannot apply unkown directive ${nameOrDefOrDirective}`, source); + throw new GraphQLError(`Cannot apply unkown directive ${nameOrDefOrDirective}`); } name = nameOrDefOrDirective; } else { this.checkUpdate(nameOrDefOrDirective); name = nameOrDefOrDirective.name; } - toAdd = new Directive(name, args ?? Object.create(null)); + toAdd = new Directive(name, args ?? Object.create(null)); Element.prototype['setParent'].call(toAdd, this); - toAdd.sourceAST = source; } // TODO: we should typecheck arguments or our TApplicationArgs business is just a lie. this._appliedDirectives.push(toAdd); @@ -568,17 +570,15 @@ export class SchemaDefinition extends SchemaElement { return this._roots.get(rootType); } - setRoot(rootType: SchemaRoot, type: ObjectType): ObjectType; - setRoot(rootType: SchemaRoot, name: string, source?: ASTNode): ObjectType; - setRoot(rootType: SchemaRoot, nameOrType: ObjectType | string, source?: ASTNode): ObjectType { + setRoot(rootType: SchemaRoot, nameOrType: ObjectType | string): ObjectType { let toSet: ObjectType; if (typeof nameOrType === 'string') { this.checkUpdate(); const obj = this.schema()!.type(nameOrType); if (!obj) { - throw new GraphQLError(`Cannot set schema ${rootType} root to unknown type ${nameOrType}`, source); + throw new GraphQLError(`Cannot set schema ${rootType} root to unknown type ${nameOrType}`); } else if (obj.kind != 'ObjectType') { - throw new GraphQLError(`Cannot set schema ${rootType} root to non-object type ${nameOrType} (of type ${obj.kind})`, source); + throw new GraphQLError(`Cannot set schema ${rootType} root to non-object type ${nameOrType} (of type ${obj.kind})`); } toSet = obj; } else { @@ -586,7 +586,6 @@ export class SchemaDefinition extends SchemaElement { toSet = nameOrType; } this._roots.set(rootType, toSet); - this.sourceAST = source; addReferenceToType(this, toSet); return toSet; } @@ -632,15 +631,15 @@ abstract class FieldBasedType extends B return this._interfaces.some(i => i.name == name); } - addImplementedInterface(nameOrItf: InterfaceType | string, source?: ASTNode): InterfaceType { + addImplementedInterface(nameOrItf: InterfaceType | string): InterfaceType { let toAdd: InterfaceType; if (typeof nameOrItf === 'string') { this.checkUpdate(); const itf = this.schema()!.type(nameOrItf); if (!itf) { - throw new GraphQLError(`Cannot implement unkown type ${nameOrItf}`, source); + throw new GraphQLError(`Cannot implement unkown type ${nameOrItf}`); } else if (itf.kind != 'InterfaceType') { - throw new GraphQLError(`Cannot implement non-interface type ${nameOrItf} (of type ${itf.kind})`, source); + throw new GraphQLError(`Cannot implement non-interface type ${nameOrItf} (of type ${itf.kind})`); } toAdd = itf; } else { @@ -662,18 +661,22 @@ abstract class FieldBasedType extends B return this._fields.get(name); } - addField(field: FieldDefinition): FieldDefinition; - addField(name: string, type?: OutputType): FieldDefinition; addField(nameOrField: string | FieldDefinition, type?: OutputType): FieldDefinition { - const toAdd = typeof nameOrField === 'string' ? new FieldDefinition(nameOrField) : nameOrField; - this.checkUpdate(toAdd); + 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 buildError(`Field ${toAdd.name} already exists on ${this}`); } this._fields.set(toAdd.name, toAdd); 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') { + if (type) { toAdd.type = type; } return toAdd; @@ -726,17 +729,15 @@ export class UnionType extends BaseNamedType { return this._types; } - addType(type: ObjectType): ObjectType; - addType(name: string, source?: ASTNode): ObjectType; - addType(nameOrType: ObjectType | string, source?: ASTNode): ObjectType { + addType(nameOrType: ObjectType | string): ObjectType { let toAdd: ObjectType; if (typeof nameOrType === 'string') { this.checkUpdate(); const obj = this.schema()!.type(nameOrType); if (!obj) { - throw new GraphQLError(`Cannot implement unkown type ${nameOrType}`, source); + throw new GraphQLError(`Cannot implement unkown type ${nameOrType}`); } else if (obj.kind != 'ObjectType') { - throw new GraphQLError(`Cannot implement non-object type ${nameOrType} (of type ${obj.kind})`, source); + throw new GraphQLError(`Cannot implement non-object type ${nameOrType} (of type ${obj.kind})`); } toAdd = obj; } else { @@ -1193,10 +1194,14 @@ export class Directive): boolean { const entries = Object.entries(this._args); if (entries.length !== Object.keys(expectedArgs).length) { From 201d51f8930ffc673c03241fddf6c995402a330f Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Mon, 21 Jun 2021 17:13:21 +0200 Subject: [PATCH 16/22] Cleanup tests (sort of) --- core-js/src/__tests__/definitions.test.ts | 237 ++++++++++++---------- 1 file changed, 131 insertions(+), 106 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 68a09e45d..37155d103 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -31,10 +31,20 @@ declare global { interface Matchers { toHaveField(name: string, type?: Type): R; toHaveDirective(directive: DirectiveDefinition, args?: TArgs): R; + toMatchString(actual: string): R; } } } +function deIndent(str: string): string { + str = str.slice(str.search(/[^\n]/)); + const indent = str.search(/[^ ]/); + return str + .split('\n') + .map(line => line.slice(indent)) + .join('\n'); +} + expect.extend({ toHaveField(parentType: ObjectType | InterfaceType, name: string, type?: Type) { const field = parentType.field(name); @@ -90,6 +100,21 @@ expect.extend({ message: () => `Element ${element} has application of directive @${definition} but not with the requested arguments. Got applications: [${directives.join(', ')}]`, pass: false } + }, + + toMatchString(expected: string, received: string) { + const pass = this.equals(expected, deIndent(received)); + const message = pass + ? () => this.utils.matcherHint('toMatchString', undefined, undefined) + + '\n\n' + + `Expected: not ${this.printExpected(expected)}` + : () => { + return ( + this.utils.matcherHint('toMatchString', undefined, undefined,) + + '\n\n' + + this.utils.printDiffOrStringify(expected, received, 'Expected', 'Received', true)); + }; + return {received, expected, message, name: 'toMatchString', pass}; } }); @@ -114,20 +139,20 @@ test('building a simple schema programatically', () => { test('parse schema and modify', () => { - const sdl = -`schema { - query: MyQuery -} + const sdl = ` + schema { + query: MyQuery + } -type A { - f1(x: Int @inaccessible): String - f2: String @inaccessible -} + type A { + f1(x: Int @inaccessible): String + f2: String @inaccessible + } -type MyQuery { - a: A - b: Int -}`; + type MyQuery { + a: A + b: Int + }`; const schema = buildSchema(sdl, federationBuiltIns); const queryType = schema.type('MyQuery')!; @@ -138,7 +163,7 @@ type MyQuery { expect(queryType).toHaveField('a', typeA); const f2 = typeA.field('f2'); expect(f2).toHaveDirective(federationBuiltIns.inaccessibleDirective(schema)); - expect(printSchema(schema)).toBe(sdl); + expect(printSchema(schema)).toMatchString(sdl); expect(typeA).toHaveField('f1'); typeA.field('f1')!.remove(); @@ -175,27 +200,27 @@ test('removal of all directives of a schema', () => { element.appliedDirectives.forEach(d => d.remove()); } - expect(printSchema(schema)).toBe( -`directive @foo on SCHEMA | FIELD_DEFINITION + expect(printSchema(schema)).toMatchString(` + directive @foo on SCHEMA | FIELD_DEFINITION -directive @foobar on UNION + directive @foobar on UNION -directive @bar on ARGUMENT_DEFINITION + directive @bar on ARGUMENT_DEFINITION -type A { - a1: String - a2: [Int] -} + type A { + a1: String + a2: [Int] + } -type B { - b: String -} + type B { + b: String + } -type Query { - a(id: String): A -} + type Query { + a(id: String): A + } -union U = A | B`); + union U = A | B`); }); test('removal of all inacessible elements of a schema', () => { @@ -229,22 +254,22 @@ test('removal of all inacessible elements of a schema', () => { } } - expect(printSchema(schema)).toBe( -`schema @foo { - query: Query -} + expect(printSchema(schema)).toMatchString(` + schema @foo { + query: Query + } -directive @foo on SCHEMA | FIELD_DEFINITION + directive @foo on SCHEMA | FIELD_DEFINITION -directive @bar on ARGUMENT_DEFINITION + directive @bar on ARGUMENT_DEFINITION -type A { - a2: [Int] -} + type A { + a2: [Int] + } -type Query { - a(id: String @bar): A -}`); + type Query { + a(id: String @bar): A + }`); }); test('handling of interfaces', () => { @@ -308,27 +333,27 @@ test('handling of interfaces', () => { b.remove(); - expect(printSchema(schema)).toBe( -`interface I { - a: Int - b: String -} + expect(printSchema(schema)).toMatchString(` + interface I { + a: Int + b: String + } -type Query { - bestIs: [I!]! -} + type Query { + bestIs: [I!]! + } -type T1 implements I { - a: Int - b: String - c: Int -} + type T1 implements I { + a: Int + b: String + c: Int + } -type T2 implements I { - a: Int - b: String - c: String -}`); + type T2 implements I { + a: Int + b: String + c: String + }`); }); test('handling of enums', () => { @@ -362,59 +387,59 @@ test('handling of enums', () => { }); test('handling of descriptions', () => { -const sdl = -`"""A super schema full of great queries""" -schema { - query: ASetOfQueries -} - -"""Marks field that are deemed more important than others""" -directive @Important( - """The reason for the importance of this field""" - why: String = "because!" -) on FIELD_DEFINITION - -"""The actual queries of the schema""" -type ASetOfQueries { - """Returns a set of products""" - bestProducts: [Product!]! - - """Finds a product by ID""" - product( - """The ID identifying the product""" - id: ID! - ): Product -} + const sdl = ` + """A super schema full of great queries""" + schema { + query: ASetOfQueries + } -"""A product that is also a book""" -type Book implements Product { - id: ID! - description: String! + """Marks field that are deemed more important than others""" + directive @Important( + """The reason for the importance of this field""" + why: String = "because!" + ) on FIELD_DEFINITION + + """The actual queries of the schema""" + type ASetOfQueries { + """Returns a set of products""" + bestProducts: [Product!]! + + """Finds a product by ID""" + product( + """The ID identifying the product""" + id: ID! + ): Product + } - """ - Number of pages in the book. Good so the customer knows its buying a 1000 page book for instance - """ - pages: Int @Important -} + """A product that is also a book""" + type Book implements Product { + id: ID! + description: String! + + """ + Number of pages in the book. Good so the customer knows its buying a 1000 page book for instance + """ + pages: Int @Important + } -type DVD implements Product { - id: ID! - description: String! + type DVD implements Product { + id: ID! + description: String! - """The film author""" - author: String @Important(why: "it's good to give credit!") -} + """The film author""" + author: String @Important(why: "it's good to give credit!") + } -"""Common interface to all our products""" -interface Product { - """Identifies the product""" - id: ID! + """Common interface to all our products""" + interface Product { + """Identifies the product""" + id: ID! - """ - Something that explains what the product is. This can just be the title of the product, but this can be more than that if we want to. But it should be useful you know, otherwise our customer won't buy it. - """ - description: String! -}`; + """ + Something that explains what the product is. This can just be the title of the product, but this can be more than that if we want to. But it should be useful you know, otherwise our customer won't buy it. + """ + description: String! + }`; const schema = buildSchema(sdl); // Checking we get back the schema through printing it is mostly good enough, but let's just @@ -425,5 +450,5 @@ interface Product { expectInterfaceType(product); expect(product.field('description')!.description).toBe(longComment); - expect(printSchema(schema)).toBe(sdl); + expect(printSchema(schema)).toMatchString(sdl); }); From e9f88d3d1ba326858fcd84f0a928485cee68feeb Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Mon, 21 Jun 2021 11:35:03 +0200 Subject: [PATCH 17/22] Handle extensions The "design" of extensions tries to optimize for extensions being supported, but not getting in the way. The assumption is that the most code are likely to not want to care if something is an extension or a proper definition. So a type is still just one object, regardless of whether it's components comes from both a definition, an extension or a mix of those. However, each type lists how many extensions it's been built from, and each direct component of the type lists if it comes from an extension. In other wordds, it's easy to ignore whether something comes from extensions or not, but it's easy to know if a particular type element was originally defined in an extension or not. Rebuilding a particular extension however do require to iterate on the elements of the type and filter those that belong to the extension we care about. Alternative designs have been considered: - having extensions be completely distinct objects from definitions. However, it feels painful that for a given type name you'll always have to potentially deal with multiple objects (a definition and a list of potential extensions). - Have a hierachy where we have an object for each type, but also separate object for defintiion and extensions. The "type" object would point to it's constituent (definition and/or extensions) but would also have methods to conveniently access all the elements of the type. In other world, we'd have `ObjectTypeDefinition` and `ObjecTypeExtensions`, both of which my have `fields`, and then we have `ObjectType` that points to (at most) an `ObjectTypeDefinition` and some `ObjectTypeExtensions` and also expose `fields` which is just a merged iterator over the fields in the definition and extensions. In term of exposing extension, this would be fairly clean. However, this require a lot of reorganisation of the library and quite a bit of new absctractions. Concept like `SchemaElement` gets a bit more complicated (you don't want `schema.allSchemaElement`). to return both `ObjectType` _and_ `ObjectTypeDefinition`/`ObjectTypeExtension` because that would duplicate things, but you still want to think of all of those as "schema elements" in other context, and it's unclear how to not make all of this overly complex. The 'parent' of elements like fields also become a more complex thing, etc... - A mix of previous points where you only have `ObjectTypeDefinition` and `ObjectTypeExtension`, but `ObjectTypeDefinition` has methods like `ownFields` and `fields` (the later return both `ownFields` and the extensions fields). While this simplify some of the question of the previous point but not all of them (this still complicate the hiearchy quite a bit) and this introduces other awkwardness (for instance, having `directives` on an `ObjectTypeDefinition` also including the directives of the extensions can be a tad suprising at first, but if you call it `allDirectives` to avoid the confusion, now you get some inconsistencies with other elements so it's not perfect either. Tl;dr, while the current design has minor downside, the alternative have other ones and feels more complex overall. --- core-js/src/__tests__/definitions.test.ts | 74 +++- core-js/src/buildSchema.ts | 95 +++- core-js/src/definitions.ts | 507 +++++++++++++++++----- core-js/src/print.ts | 177 +++++--- 4 files changed, 654 insertions(+), 199 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 37155d103..5c2637086 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -5,7 +5,8 @@ import { DirectiveDefinition, InterfaceType, EnumType, - SchemaElement + SchemaElement, + UnionType } from '../../dist/definitions'; import { printSchema } from '../../dist/print'; import { buildSchema } from '../../dist/buildSchema'; @@ -21,6 +22,11 @@ function expectInterfaceType(type?: Type): asserts type is InterfaceType { expect(type!.kind).toBe('InterfaceType'); } +function expectUnionType(type?: Type): asserts type is UnionType { + expect(type).toBeDefined(); + expect(type!.kind).toBe('UnionType'); +} + function expectEnumType(type?: Type): asserts type is EnumType { expect(type).toBeDefined(); expect(type!.kind).toBe('EnumType'); @@ -37,7 +43,12 @@ declare global { } function deIndent(str: string): string { + // Strip leading \n str = str.slice(str.search(/[^\n]/)); + // Strip trailing \n or space + while (str.charAt(str.length - 1) === '\n' || str.charAt(str.length - 1) === ' ') { + str = str.slice(0, str.length - 1); + } const indent = str.search(/[^ ]/); return str .split('\n') @@ -72,7 +83,7 @@ expect.extend({ } }, - toHaveDirective(element: SchemaElement, definition: DirectiveDefinition, args?: Record) { + toHaveDirective(element: SchemaElement, definition: DirectiveDefinition, args?: Record) { const directives = element.appliedDirectivesOf(definition); if (directives.length == 0) { return { @@ -103,7 +114,8 @@ expect.extend({ }, toMatchString(expected: string, received: string) { - const pass = this.equals(expected, deIndent(received)); + received = deIndent(received); + const pass = this.equals(expected, received); const message = pass ? () => this.utils.matcherHint('toMatchString', undefined, undefined) + '\n\n' @@ -120,7 +132,7 @@ expect.extend({ test('building a simple schema programatically', () => { const schema = new Schema(federationBuiltIns); - const queryType = schema.schemaDefinition.setRoot('query', schema.addType(new ObjectType('Query'))); + const queryType = schema.schemaDefinition.setRoot('query', schema.addType(new ObjectType('Query'))).type; const typeA = schema.addType(new ObjectType('A')); const inaccessible = federationBuiltIns.inaccessibleDirective(schema); const key = federationBuiltIns.keyDirective(schema); @@ -130,7 +142,7 @@ test('building a simple schema programatically', () => { typeA.applyDirective(inaccessible); typeA.applyDirective(key, { fields: 'a'}); - expect(queryType).toBe(schema.schemaDefinition.root('query')); + expect(queryType).toBe(schema.schemaDefinition.root('query')!.type); expect(queryType).toHaveField('a', typeA); expect(typeA).toHaveField('q', queryType); expect(typeA).toHaveDirective(inaccessible); @@ -159,7 +171,7 @@ test('parse schema and modify', () => { const typeA = schema.type('A')!; expectObjectType(queryType); expectObjectType(typeA); - expect(schema.schemaDefinition.root('query')).toBe(queryType); + expect(schema.schemaDefinition.root('query')!.type).toBe(queryType); expect(queryType).toHaveField('a', typeA); const f2 = typeA.field('f2'); expect(f2).toHaveDirective(federationBuiltIns.inaccessibleDirective(schema)); @@ -248,7 +260,7 @@ test('removal of all inacessible elements of a schema', () => { directive @bar on ARGUMENT_DEFINITION `, federationBuiltIns); - for (const element of schema.allSchemaElement()) { + for (const element of schema.allNamedSchemaElement()) { if (element.appliedDirectivesOf(federationBuiltIns.inaccessibleDirective(schema)).length > 0) { element.remove(); } @@ -452,3 +464,51 @@ test('handling of descriptions', () => { expect(printSchema(schema)).toMatchString(sdl); }); + +test('handling of extensions', () => { + const sdl = ` + extend schema { + query: AType + } + + interface AInterface { + i1: Int + } + + extend interface AInterface @deprecated + + scalar AScalar + + extend scalar AScalar @deprecated + + extend type AType { + t1: Int + t2: String + } + + type AType2 { + t1: String + } + + type AType3 { + t2: Int + } + + union AUnion = AType | AType2 + + extend union AUnion = AType3 + `; + + const schema = buildSchema(sdl); + expect(printSchema(schema)).toMatchString(sdl); + + const atype = schema.type('AType'); + expectObjectType(atype); + expect(atype).toHaveField('t1', schema.intType()); + expect(atype).toHaveField('t2', schema.stringType()); + + const aunion = schema.type('AUnion'); + expectUnionType(aunion); + expect([...aunion.types()].map(t => t.name)).toEqual(['AType', 'AType2', 'AType3']); + +}); diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index 072540128..f874c8371 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -16,7 +16,8 @@ import { NamedTypeNode, ArgumentNode, StringValueNode, - ASTNode + ASTNode, + SchemaExtensionNode } from "graphql"; import { Maybe } from "graphql/jsutils/Maybe"; import { @@ -43,7 +44,8 @@ import { DirectiveDefinition, UnionType, InputObjectType, - EnumType + EnumType, + Extension } from "./definitions"; function buildValue(value?: ValueNode): any { @@ -75,6 +77,12 @@ export function buildSchemaFromAST(documentNode: DocumentNode, builtIns: BuiltIn case 'SchemaDefinition': buildSchemaDefinitionInner(definitionNode, schema.schemaDefinition); break; + case 'SchemaExtension': + buildSchemaDefinitionInner( + definitionNode, + schema.schemaDefinition, + schema.schemaDefinition.newExtension()); + break; case 'ScalarTypeDefinition': case 'ObjectTypeDefinition': case 'InterfaceTypeDefinition': @@ -83,17 +91,20 @@ export function buildSchemaFromAST(documentNode: DocumentNode, builtIns: BuiltIn case 'InputObjectTypeDefinition': buildNamedTypeInner(definitionNode, schema.type(definitionNode.name.value)!); break; - case 'DirectiveDefinition': - buildDirectiveDefinitionInner(definitionNode, schema.directive(definitionNode.name.value)!); - break; - case 'SchemaExtension': case 'ScalarTypeExtension': case 'ObjectTypeExtension': case 'InterfaceTypeExtension': case 'UnionTypeExtension': case 'EnumTypeExtension': case 'InputObjectTypeExtension': - throw new Error("Extensions are a TODO"); + const toExtend = schema.type(definitionNode.name.value)!; + const extension = toExtend.newExtension(); + extension.sourceAST = definitionNode; + buildNamedTypeInner(definitionNode, toExtend, extension); + break; + case 'DirectiveDefinition': + buildDirectiveDefinitionInner(definitionNode, schema.directive(definitionNode.name.value)!); + break; } } return schema; @@ -108,7 +119,16 @@ function buildNamedTypeAndDirectivesShallow(documentNode: DocumentNode, schema: case 'UnionTypeDefinition': case 'EnumTypeDefinition': case 'InputObjectTypeDefinition': - schema.addType(newNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value)); + case 'ScalarTypeExtension': + case 'ObjectTypeExtension': + case 'InterfaceTypeExtension': + case 'UnionTypeExtension': + case 'EnumTypeExtension': + case 'InputObjectTypeExtension': + // Note that because of extensions, this may be called multiple times for the same type. + if (!schema.type(definitionNode.name.value)) { + schema.addType(newNamedType(withoutTrailingDefinition(definitionNode.kind), definitionNode.name.value)); + } break; case 'DirectiveDefinition': schema.addDirectiveDefinition(definitionNode.name.value); @@ -122,7 +142,8 @@ type NodeWithDescription = {description?: Maybe}; type NodeWithArguments = {arguments?: ReadonlyArray}; function withoutTrailingDefinition(str: string): NamedTypeKind { - return str.slice(0, str.length - 'Definition'.length) as NamedTypeKind; + const endString = str.endsWith('Definition') ? 'Definition' : 'Extension'; + return str.slice(0, str.length - endString.length) as NamedTypeKind; } function getReferencedType(node: NamedTypeNode, schema: Schema): NamedType { @@ -145,7 +166,7 @@ function withNodeAttachedToError(operation: () => void, node: ASTNode) { e.source, e.positions, e.path, - e.originalError, + e, e.extensions ); } else { @@ -154,19 +175,30 @@ function withNodeAttachedToError(operation: () => void, node: ASTNode) { } } -function buildSchemaDefinitionInner(schemaNode: SchemaDefinitionNode, schemaDefinition: SchemaDefinition) { - for (const opTypeNode of schemaNode.operationTypes) { - withNodeAttachedToError(() => schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value), opTypeNode); +function buildSchemaDefinitionInner( + schemaNode: SchemaDefinitionNode | SchemaExtensionNode, + schemaDefinition: SchemaDefinition, + extension?: Extension +) { + for (const opTypeNode of schemaNode.operationTypes ?? []) { + withNodeAttachedToError( + () => schemaDefinition.setRoot(opTypeNode.operation, opTypeNode.type.name.value).setOfExtension(extension), + opTypeNode); } schemaDefinition.sourceAST = schemaNode; - schemaDefinition.description = schemaNode.description?.value; - buildAppliedDirectives(schemaNode, schemaDefinition); + schemaDefinition.description = 'description' in schemaNode ? schemaNode.description?.value : undefined; + buildAppliedDirectives(schemaNode, schemaDefinition, extension); } -function buildAppliedDirectives(elementNode: NodeWithDirectives, element: SchemaElement) { +function buildAppliedDirectives( + elementNode: NodeWithDirectives, + element: SchemaElement, + extension?: Extension +) { for (const directive of elementNode.directives ?? []) { withNodeAttachedToError(() => { const d = element.applyDirective(directive.name.value, buildArgs(directive)); + d.setOfExtension(extension); d.sourceAST = directive; }, directive); } @@ -180,38 +212,55 @@ function buildArgs(argumentsNode: NodeWithArguments): Record { return args; } -function buildNamedTypeInner(definitionNode: DefinitionNode & NodeWithDirectives & NodeWithDescription, type: NamedType) { +function buildNamedTypeInner( + definitionNode: DefinitionNode & NodeWithDirectives & NodeWithDescription, + type: NamedType, + extension?: Extension +) { switch (definitionNode.kind) { case 'ObjectTypeDefinition': + case 'ObjectTypeExtension': case 'InterfaceTypeDefinition': + case 'InterfaceTypeExtension': const fieldBasedType = type as ObjectType | InterfaceType; for (const fieldNode of definitionNode.fields ?? []) { - buildFieldDefinitionInner(fieldNode, fieldBasedType.addField(fieldNode.name.value)); + const field = fieldBasedType.addField(fieldNode.name.value); + field.setOfExtension(extension); + buildFieldDefinitionInner(fieldNode, field); } for (const itfNode of definitionNode.interfaces ?? []) { - withNodeAttachedToError(() => fieldBasedType.addImplementedInterface(itfNode.name.value), itfNode); + withNodeAttachedToError( + () => fieldBasedType.addImplementedInterface(itfNode.name.value).setOfExtension(extension), + itfNode); } break; case 'UnionTypeDefinition': + case 'UnionTypeExtension': const unionType = type as UnionType; for (const namedType of definitionNode.types ?? []) { - withNodeAttachedToError(() => unionType.addType(namedType.name.value), namedType); + withNodeAttachedToError( + () => unionType.addType(namedType.name.value).setOfExtension(extension), + namedType); } break; case 'EnumTypeDefinition': + case 'EnumTypeExtension': const enumType = type as EnumType; for (const enumVal of definitionNode.values ?? []) { - enumType.addValue(enumVal.name.value); + enumType.addValue(enumVal.name.value).setOfExtension(extension); } break; case 'InputObjectTypeDefinition': + case 'InputObjectTypeExtension': const inputObjectType = type as InputObjectType; for (const fieldNode of definitionNode.fields ?? []) { - buildInputFieldDefinitionInner(fieldNode, inputObjectType.addField(fieldNode.name.value)); + const field = inputObjectType.addField(fieldNode.name.value); + field.setOfExtension(extension); + buildInputFieldDefinitionInner(fieldNode, field); } break; } - buildAppliedDirectives(definitionNode, type); + buildAppliedDirectives(definitionNode, type, extension); type.description = definitionNode.description?.value; type.sourceAST = definitionNode; } diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 2984584d0..be83aab06 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -27,10 +27,6 @@ export type NullableType = NamedType | ListType; export type NamedTypeKind = NamedType['kind']; -export function defaultRootTypeName(root: SchemaRoot) { - return root.charAt(0).toUpperCase() + root.slice(1); -} - export function isNamedType(type: Type): type is NamedType { return type instanceof BaseNamedType; } @@ -87,7 +83,9 @@ export interface Named { readonly name: string; } -// Note exposed: mostly about avoid code duplication between SchemaElement and Directive (which is not a SchemaElement as it can't +export type ExtendableElement = SchemaDefinition | NamedType; + +// 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> { protected _parent?: TParent; @@ -112,9 +110,35 @@ abstract class Element | Schema> { } protected setParent(parent: TParent) { - assert(!this._parent, "Cannot set parent of a non-detached element"); + assert(!this._parent, "Cannot set parent of an already attached element"); this._parent = parent; } + + 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 hierachy 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. + if (!this.schema()) { + throw buildError(`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; + } } export abstract class SchemaElement | Schema> extends Element { @@ -169,7 +193,7 @@ export abstract class SchemaElement | Schema> return false; } - protected removeTypeReferenceInternal(type: BaseNamedType) { + 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 impemented by each concrete subclass. // As we can't have both at the same time, this method just delegate to `remoteTypeReference` which is genuinely @@ -193,15 +217,7 @@ export abstract class SchemaElement | Schema> if (this.isElementBuiltIn() && !Schema.prototype['canModifyBuiltIn'].call(this.schema()!)) { throw buildError(`Cannot modify built-in ${this}`); } - // 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 hierachy 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. - if (!this.schema()) { - throw buildError(`Cannot modify detached element ${this}`); - } + super.checkUpdate(); if (addedElement) { const thatSchema = addedElement.schema(); if (thatSchema && thatSchema != this.schema()) { @@ -221,8 +237,9 @@ export abstract class NamedSchemaElement extends NamedSchemaElement { +abstract class BaseNamedType extends NamedSchemaElement { protected readonly _referencers: Set = new Set(); + protected readonly _extensions: Set> = new Set(); constructor(name: string, readonly isBuiltIn: boolean = false) { super(name); @@ -244,6 +261,28 @@ abstract class BaseNamedType extends NamedSchemaElement> { + return this._extensions; + } + + 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._extensions.has(extension)) { + return extension; + } + if (extension.extendedElement) { + throw buildError(`Cannot add extension to type ${this}: it is already added to another type`); + } + this._extensions.add(extension); + Extension.prototype['setExtendedElement'].call(extension, this); + return extension; + } + protected isElementBuiltIn(): boolean { return this.isBuiltIn; } @@ -318,7 +357,32 @@ abstract class BaseNamedElementWithType extends Element { + private _extension?: Extension; + + ofExtension(): Extension | undefined { + return this._extension; + } + + setOfExtension(extension: Extension | undefined) { + this.checkUpdate(); + // See similar comment on FieldDefinition.setOfExtension for why we have to cast. + if (extension && !this.parent?.extensions().has(extension as any)) { + throw buildError(`Cannot set object as part of the provided extension: it is not an extension of parent ${this.parent}`); + } + this._extension = extension; + } + + remove() { + this.removeInner(); + this._extension = undefined; + this._parent = undefined; + } + + protected abstract removeInner(): void; } export class BuiltIns { @@ -406,7 +470,7 @@ export class Schema { return !this.isConstructed; } - private removeTypeInternal(type: BaseNamedType) { + private removeTypeInternal(type: BaseNamedType) { this._types.delete(type.name); } @@ -558,42 +622,90 @@ export class Schema { } } +export class RootType extends BaseExtensionMember { + constructor(readonly rootKind: SchemaRoot, readonly type: ObjectType) { + super(); + } + + isDefaultRootName() { + return this.rootKind.charAt(0).toUpperCase() + this.rootKind.slice(1) == 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: Map = new Map(); + protected readonly _roots: Map = new Map(); + protected readonly _extensions: Set> = new Set(); - get roots(): ReadonlyMap { - return this._roots; + *roots(): Generator { + yield* this._roots.values(); } - root(rootType: SchemaRoot): ObjectType | undefined { - return this._roots.get(rootType); + root(rootKind: SchemaRoot): RootType | undefined { + return this._roots.get(rootKind); } - setRoot(rootType: SchemaRoot, nameOrType: ObjectType | string): ObjectType { - let toSet: ObjectType; + setRoot(rootKind: SchemaRoot, nameOrType: ObjectType | string): RootType { + let toSet: RootType; if (typeof nameOrType === 'string') { this.checkUpdate(); const obj = this.schema()!.type(nameOrType); if (!obj) { - throw new GraphQLError(`Cannot set schema ${rootType} root to unknown type ${nameOrType}`); + throw new GraphQLError(`Cannot set schema ${rootKind} root to unknown type ${nameOrType}`); } else if (obj.kind != 'ObjectType') { - throw new GraphQLError(`Cannot set schema ${rootType} root to non-object type ${nameOrType} (of type ${obj.kind})`); + throw new GraphQLError(`Cannot set schema ${rootKind} root to non-object type ${nameOrType} (of type ${obj.kind})`); } - toSet = obj; + toSet = new RootType(rootKind, obj); } else { this.checkUpdate(nameOrType); - toSet = nameOrType; + toSet = new RootType(rootKind, nameOrType); } - this._roots.set(rootType, toSet); - addReferenceToType(this, toSet); + 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); return toSet; } + extensions(): ReadonlySet> { + return this._extensions; + } + + 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._extensions.has(extension)) { + return extension; + } + if (extension.extendedElement) { + throw buildError(`Cannot add extension to this schema: extension is already added to another schema`); + } + this._extensions.add(extension); + Extension.prototype['setExtendedElement'].call(extension, this); + return extension; + } + + private removeRootType(rootType: RootType) { + this._roots.delete(rootType.rootKind); + removeReferenceToType(this, rootType.type); + } + protected removeTypeReference(toRemove: NamedType) { - for (const [root, type] of this._roots) { - if (type == toRemove) { - this._roots.delete(root); + for (const rootType of this.roots()) { + if (rootType.type == toRemove) { + this._roots.delete(rootType.rootKind); } } } @@ -603,7 +715,7 @@ export class SchemaDefinition extends SchemaElement { } } -export class ScalarType extends BaseNamedType { +export class ScalarType extends BaseNamedType { readonly kind = 'ScalarType' as const; protected removeTypeReference(type: NamedType) { @@ -615,40 +727,72 @@ export class ScalarType extends BaseNamedType extends BaseNamedType { - protected readonly _interfaces: InterfaceType[] = []; +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); + } +} + +abstract class FieldBasedType 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. + protected readonly _interfaceImplementations: Map> = new Map(); protected readonly _fields: Map> = new Map(); private removeFieldInternal(field: FieldDefinition) { this._fields.delete(field.name); } - get interfaces(): readonly InterfaceType[] { - return this._interfaces; + *interfaceImplementations(): Generator, void, undefined> { + yield* this._interfaceImplementations.values(); + } + + *interfaces(): Generator { + for (const impl of this._interfaceImplementations.values()) { + yield impl.interface; + } } implementsInterface(name: string): boolean { - return this._interfaces.some(i => i.name == name); + return this._interfaceImplementations.has(name); } - addImplementedInterface(nameOrItf: InterfaceType | string): InterfaceType { - let toAdd: InterfaceType; - if (typeof nameOrItf === 'string') { - this.checkUpdate(); - const itf = this.schema()!.type(nameOrItf); - if (!itf) { - throw new GraphQLError(`Cannot implement unkown type ${nameOrItf}`); - } else if (itf.kind != 'InterfaceType') { - throw new GraphQLError(`Cannot implement non-interface type ${nameOrItf} (of type ${itf.kind})`); - } - toAdd = itf; + addImplementedInterface(nameOrItfOrItfImpl: InterfaceImplementation | InterfaceType | string): InterfaceImplementation { + let toAdd: InterfaceImplementation; + if (nameOrItfOrItfImpl instanceof InterfaceImplementation) { + this.checkUpdate(nameOrItfOrItfImpl); + toAdd = nameOrItfOrItfImpl; } else { - this.checkUpdate(nameOrItf); - toAdd = nameOrItf; + let itf: InterfaceType; + if (typeof nameOrItfOrItfImpl === 'string') { + this.checkUpdate(); + const maybeItf = this.schema()!.type(nameOrItfOrItfImpl); + if (!maybeItf) { + throw new GraphQLError(`Cannot implement unkown type ${nameOrItfOrItfImpl}`); + } else if (maybeItf.kind != 'InterfaceType') { + throw new GraphQLError(`Cannot implement non-interface type ${nameOrItfOrItfImpl} (of type ${maybeItf.kind})`); + } + itf = maybeItf; + } else { + itf = nameOrItfOrItfImpl; + } + toAdd = new InterfaceImplementation(itf); } - if (!this._interfaces.includes(toAdd)) { - this._interfaces.push(toAdd); - addReferenceToType(this, toAdd); + if (!this._interfaceImplementations.has(toAdd.interface.name)) { + this._interfaceImplementations.set(toAdd.interface.name, toAdd); + addReferenceToType(this, toAdd.interface); + Element.prototype['setParent'].call(toAdd, this); } return toAdd; } @@ -689,15 +833,19 @@ abstract class FieldBasedType extends B } } + private removeInterfaceImplementation(itf: InterfaceType) { + this._interfaceImplementations.delete(itf.name); + removeReferenceToType(this, itf); + } + protected removeTypeReference(type: NamedType) { - const index = this._interfaces.indexOf(type as InterfaceType); - if (index >= 0) { - this._interfaces.splice(index, 1); - } + this._interfaceImplementations.delete(type.name); } protected removeInnerElements(): void { - this._interfaces.splice(0, this._interfaces.length); + for (const interfaceImpl of this._interfaceImplementations.values()) { + interfaceImpl.remove(); + } for (const field of this._fields.values()) { field.remove(); } @@ -721,49 +869,77 @@ export class InterfaceType extends FieldBasedType { +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; - protected readonly _types: ObjectType[] = []; + protected readonly _members: Map = new Map(); - get types(): readonly ObjectType[] { - return this._types; + *types(): Generator { + for (const member of this._members.values()) { + yield member.type; + } } - addType(nameOrType: ObjectType | string): ObjectType { - let toAdd: ObjectType; - if (typeof nameOrType === 'string') { - this.checkUpdate(); - const obj = this.schema()!.type(nameOrType); - if (!obj) { - throw new GraphQLError(`Cannot implement unkown type ${nameOrType}`); - } else if (obj.kind != 'ObjectType') { - throw new GraphQLError(`Cannot implement non-object type ${nameOrType} (of type ${obj.kind})`); - } - toAdd = obj; + *members(): Generator { + yield* this._members.values(); + } + + addType(nameOrTypeOrMember: ObjectType | string | UnionMember): UnionMember { + let toAdd: UnionMember; + if (nameOrTypeOrMember instanceof UnionMember) { + this.checkUpdate(nameOrTypeOrMember); + toAdd = nameOrTypeOrMember; } else { - this.checkUpdate(nameOrType); - toAdd = nameOrType; + let obj: ObjectType; + if (typeof nameOrTypeOrMember === 'string') { + this.checkUpdate(); + const maybeObj = this.schema()!.type(nameOrTypeOrMember); + if (!maybeObj) { + throw new GraphQLError(`Cannot implement unkown type ${nameOrTypeOrMember}`); + } else if (maybeObj.kind != 'ObjectType') { + throw new GraphQLError(`Cannot implement non-object type ${nameOrTypeOrMember} (of type ${maybeObj.kind})`); + } + obj = maybeObj; + } else { + this.checkUpdate(nameOrTypeOrMember); + obj = nameOrTypeOrMember; + } + toAdd = new UnionMember(obj); } - if (!this._types.includes(toAdd)) { - this._types.push(toAdd); - addReferenceToType(this, toAdd); + if (!this._members.has(toAdd.type.name)) { + this._members.set(toAdd.type.name, toAdd); + Element.prototype['setParent'].call(toAdd, this); + addReferenceToType(this, toAdd.type); } return toAdd; } + private removeMember(type: ObjectType) { + this._members.delete(type.name); + removeReferenceToType(this, type); + } + protected removeTypeReference(type: NamedType) { - const index = this._types.indexOf(type as ObjectType); - if (index >= 0) { - this._types.splice(index, 1); - } + this._members.delete(type.name); } protected removeInnerElements(): void { - this._types.splice(0, this._types.length); + for (const member of this.members()) { + member.remove(); + } } } -export class EnumType extends BaseNamedType { +export class EnumType extends BaseNamedType { readonly kind = 'EnumType' as const; protected readonly _values: EnumValue[] = []; @@ -788,6 +964,7 @@ export class EnumType extends BaseNamedType { } if (!this._values.includes(toAdd)) { this._values.push(toAdd); + Element.prototype['setParent'].call(toAdd, this); } return toAdd; } @@ -808,7 +985,7 @@ export class EnumType extends BaseNamedType { } } -export class InputObjectType extends BaseNamedType { +export class InputObjectType extends BaseNamedType { readonly kind = 'InputObjectType' as const; private readonly _fields: Map = new Map(); @@ -899,6 +1076,7 @@ export class NonNullType extends BaseWrapperType { export class FieldDefinition extends BaseNamedElementWithType { readonly kind = 'FieldDefinition' as const; private readonly _args: Map>> = new Map(); + private _extension?: Extension; get coordinate(): string { const parent = this.parent; @@ -936,6 +1114,20 @@ export class FieldDefinition extends return toAdd; } + ofExtension(): Extension | undefined { + return this._extension; + } + + setOfExtension(extension: Extension | undefined) { + this.checkUpdate(); + // It seems typscript "expand" `TParent` below into `ObjectType | Interface`, so it essentially lose the context that + // the `TParent` in `Extension` will always match. Hence the `as any`. + if (extension && !this.parent?.extensions().has(extension as any)) { + throw buildError(`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; + } + /** * Removes this field definition from its parent type. * @@ -949,6 +1141,7 @@ export class FieldDefinition extends FieldBasedType.prototype['removeFieldInternal'].call(this._parent, this); this._parent = undefined; this.type = undefined; + this._extension = undefined; for (const arg of this._args.values()) { arg.remove(); } @@ -966,12 +1159,27 @@ export class FieldDefinition extends export class InputFieldDefinition extends BaseNamedElementWithType { readonly kind = 'InputFieldDefinition' 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; + } + + setOfExtension(extension: Extension | undefined) { + this.checkUpdate(); + // It seems typscript "expand" `TParent` below into `ObjectType | Interface`, so it essentially lose the context that + // the `TParent` in `Extension` will always match. Hence the `as any`. + if (extension && !this.parent?.extensions().has(extension as any)) { + throw buildError(`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; + } + /** * Removes this field definition from its parent type. * @@ -1032,12 +1240,25 @@ export class ArgumentDefinition | Directive 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; + } + + setOfExtension(extension: Extension | undefined) { + this.checkUpdate(); + if (extension && !this.parent?.extensions().has(extension)) { + throw buildError(`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; + } + /** * Removes this field definition from its parent type. * @@ -1060,7 +1281,7 @@ export class EnumValue extends NamedSchemaElement { } toString(): string { - return `${this.name}$`; + return `${this.name}`; } } @@ -1181,6 +1402,10 @@ export class DirectiveDefinition 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) { super(); } @@ -1219,6 +1444,25 @@ export class Directive | undefined { + return this._extension; + } + + setOfExtension(extension: Extension | undefined) { + this.checkUpdate(); + if (extension) { + const parent = this.parent!; + if (parent instanceof SchemaDefinition || parent instanceof BaseNamedType) { + if (!parent.extensions().has(extension)) { + throw buildError(`Cannot mark directive ${this.name} as part of the provided extension: it is not an extension of parent ${parent}`); + } + } else { + throw buildError(`Can only mark directive parts of extensions when directly apply to type or schema definition.`); + } + } + this._extension = extension; + } + /** * Removes this directive application from its parent type. * @@ -1233,6 +1477,7 @@ export class Directive= 0, `Directive ${this} lists ${this._parent} as parent, but that parent doesn't list it as applied directive`); parentDirectives.splice(index, 1); this._parent = undefined; + this._extension = undefined; return true; } @@ -1295,6 +1540,8 @@ export function newNamedType(kind: NamedTypeKind, name: string): NamedType { return new EnumType(name); case 'InputObjectType': return new InputObjectType(name); + default: + assert(false, `Unhandled kind ${kind} for type ${name}`); } } @@ -1330,62 +1577,88 @@ function copy(source: Schema, dest: Schema) { } } -function copySchemaDefinitionInner(source: SchemaDefinition, dest: SchemaDefinition) { - for (const [root, type] of source.roots.entries()) { - dest.setRoot(root, type.name); +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); } - copyAppliedDirectives(source, dest); - dest.sourceAST = source.sourceAST; + return extensionMap; } -function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement) { +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) { + const extensionsMap = copyExtensions(source, dest); + for (const rootType of source.roots()) { + copyOfExtension(extensionsMap, rootType, dest.setRoot(rootType.rootKind, rootType.type.name)); + } for (const directive of source.appliedDirectives) { - dest.applyDirective(directive.name, { ...directive.arguments}); + copyOfExtension(extensionsMap, directive, dest.applyDirective(directive.name, { ...directive.arguments})); } + dest.sourceAST = source.sourceAST; } function copyNamedTypeInner(source: NamedType, dest: NamedType) { - copyAppliedDirectives(source, dest); + const extensionsMap = copyExtensions(source, dest); + for (const directive of source.appliedDirectives) { + copyOfExtension(extensionsMap, directive, dest.applyDirective(directive.name, { ...directive.arguments})); + } dest.sourceAST = source.sourceAST; switch (source.kind) { case 'ObjectType': - const destObjectType = dest as ObjectType; - for (const field of source.fields.values()) { - copyFieldDefinitionInner(field, destObjectType.addField(new FieldDefinition(field.name))); - } - for (const itf of source.interfaces) { - destObjectType.addImplementedInterface(itf.name); - } - break; case 'InterfaceType': - const destInterfaceType = dest as InterfaceType; - for (const field of source.fields.values()) { - copyFieldDefinitionInner(field, destInterfaceType.addField(new FieldDefinition(field.name))); + const destFieldBasedType = dest as FieldBasedType; + for (const sourceField of source.fields.values()) { + const destField = destFieldBasedType.addField(new FieldDefinition(sourceField.name)); + copyOfExtension(extensionsMap, sourceField, destField); + copyFieldDefinitionInner(sourceField, destField); } - for (const itf of source.interfaces) { - destInterfaceType.addImplementedInterface(itf.name); + 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 type of source.types) { - destUnionType.addType(type.name); + 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 value of source.values) { - destEnumType.addValue(value.name); + for (const sourceValue of source.values) { + const destValue = destEnumType.addValue(sourceValue.name); + copyOfExtension(extensionsMap, sourceValue, destValue); } break case 'InputObjectType': const destInputType = dest as InputObjectType; - for (const field of source.fields.values()) { - copyInputFieldDefinitionInner(field, destInputType.addField(new InputFieldDefinition(field.name))); + for (const sourceField of source.fields.values()) { + const destField = destInputType.addField(new InputFieldDefinition(sourceField.name)); + copyOfExtension(extensionsMap, sourceField, destField); + copyInputFieldDefinitionInner(sourceField, destField); } } } +function copyAppliedDirectives(source: SchemaElement, dest: SchemaElement) { + for (const directive of source.appliedDirectives) { + dest.applyDirective(directive.name, { ...directive.arguments}); + } +} + function copyFieldDefinitionInner

(source: FieldDefinition

, dest: FieldDefinition

) { const type = copyWrapperTypeOrTypeRef(source.type, dest.schema()!) as OutputType; dest.type = type; diff --git a/core-js/src/print.ts b/core-js/src/print.ts index d9d023668..f27f68260 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -1,15 +1,18 @@ import { - ArgumentDefinition, - defaultRootTypeName, - DirectiveDefinition, - EnumType, - FieldDefinition, - InputFieldDefinition, - InputObjectType, - InterfaceType, - NamedType, - ObjectType, - ScalarType, + ArgumentDefinition, + Directive, + DirectiveDefinition, + EnumType, + ExtendableElement, + Extension, + FieldDefinition, + InputFieldDefinition, + InputObjectType, + InterfaceImplementation, + InterfaceType, + NamedType, + ObjectType, + ScalarType, Schema, SchemaDefinition, SchemaElement, @@ -23,22 +26,59 @@ export function printSchema(schema: Schema): string { const types = [...schema.types()] .sort((type1, type2) => type1.name.localeCompare(type2.name)); return ( - [printSchemaDefinition(schema.schemaDefinition)] + printSchemaDefinitionAndExtensions(schema.schemaDefinition) .concat( directives.map(directive => printDirectiveDefinition(directive)), - types.map(type => printTypeDefinition(type)), + types.flatMap(type => printTypeDefinitionAndExtensions(type)), ) .filter(Boolean) .join('\n\n') ); } -function printSchemaDefinition(schemaDefinition: SchemaDefinition): string | undefined { +function definitionAndExtensions(element: {extensions(): ReadonlySet>}) { + return [undefined, ...element.extensions()]; +} + +function printSchemaDefinitionAndExtensions(schemaDefinition: SchemaDefinition): string[] { if (isSchemaOfCommonNames(schemaDefinition)) { - return; + return []; + } + return printDefinitionAndExtensions(schemaDefinition, printSchemaDefinitionOrExtension); +} + +function printDefinitionAndExtensions>}>( + t: T, + printer: (t: T, extension?: Extension) => string | undefined +): string[] { + return definitionAndExtensions(t) + .map(ext => printer(t, ext)) + .filter(v => v !== undefined) as string[]; +} + +function printIsExtension(extension?: Extension): string { + return extension ? 'extend ' : ''; +} + +function forExtension | undefined}>(ts: readonly T[], extension?: Extension): T[] { + return ts.filter(r => r.ofExtension() === extension); +} + +function printSchemaDefinitionOrExtension( + schemaDefinition: SchemaDefinition, + extension?: Extension +): string | undefined { + const roots = forExtension([...schemaDefinition.roots()], extension); + const directives = forExtension(schemaDefinition.appliedDirectives, extension); + if (!roots.length && !directives.length) { + return undefined; } - const rootEntries = [...schemaDefinition.roots.entries()].map(([root, type]) => `${indent}${root}: ${type}`); - return `${printDescription(schemaDefinition)}schema${printAppliedDirectives(schemaDefinition)} {\n${rootEntries.join('\n')}\n}`; + const rootEntries = roots.map((rootType) => `${indent}${rootType.rootKind}: ${rootType.type}`); + return printDescription(schemaDefinition) + + printIsExtension(extension) + + 'schema' + + printAppliedDirectives(directives) + + ' {\n' + rootEntries.join('\n') + '\n}'; } /** @@ -54,25 +94,17 @@ function printSchemaDefinition(schemaDefinition: SchemaDefinition): string | und * When using this naming convention, the schema description can be omitted. */ function isSchemaOfCommonNames(schema: SchemaDefinition): boolean { - if (schema.appliedDirectives.length > 0 || schema.description) { - return false; - } - for (const [root, type] of schema.roots) { - if (type.name != defaultRootTypeName(root)) { - return false; - } - } - return true; + return schema.appliedDirectives.length === 0 && !schema.description && [...schema.roots()].every(r => r.isDefaultRootName()); } -export function printTypeDefinition(type: NamedType): string { +export function printTypeDefinitionAndExtensions(type: NamedType): string[] { switch (type.kind) { - case 'ScalarType': return printScalarType(type); - case 'ObjectType': return printFieldBasedType('type', type); - case 'InterfaceType': return printFieldBasedType('interface', type); - case 'UnionType': return printUnionType(type); - case 'EnumType': return printEnumType(type); - case 'InputObjectType': return printInputObjectType(type); + case 'ScalarType': return printDefinitionAndExtensions(type, printScalarDefinitionOrExtension); + case 'ObjectType': return printDefinitionAndExtensions(type, (t, ext) => printFieldBasedTypeDefinitionOrExtension('type', t, ext)); + case 'InterfaceType': return printDefinitionAndExtensions(type, (t, ext) => printFieldBasedTypeDefinitionOrExtension('interface', t, ext)); + case 'UnionType': return printDefinitionAndExtensions(type, printUnionDefinitionOrExtension); + case 'EnumType': return printDefinitionAndExtensions(type, printEnumDefinitionOrExtension); + case 'InputObjectType': return printDefinitionAndExtensions(type, printInputDefinitionOrExtension); } } @@ -81,8 +113,7 @@ export function printDirectiveDefinition(directive: DirectiveDefinition): string return `${printDescription(directive)}directive @${directive}${printArgs([...directive.arguments()])}${directive.repeatable ? ' repeatable' : ''} on ${locations}`; } -function printAppliedDirectives(element: SchemaElement): string { - const appliedDirectives = element.appliedDirectives; +function printAppliedDirectives(appliedDirectives: readonly Directive[]): string { return appliedDirectives.length == 0 ? "" : " " + appliedDirectives.map(d => d.toString()).join(" "); } @@ -103,36 +134,78 @@ function printDescription( return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; } -function printScalarType(type: ScalarType): string { - return `${printDescription(type)}scalar ${type.name}${printAppliedDirectives(type)}` +function printScalarDefinitionOrExtension(type: ScalarType, extension?: Extension): string | undefined { + const directives = forExtension(type.appliedDirectives, extension); + if (extension && !directives.length) { + return undefined; + } + return `${printDescription(type)}${printIsExtension(extension)}scalar ${type.name}${printAppliedDirectives(directives)}` } -function printImplementedInterfaces(type: ObjectType | InterfaceType): string { - return type.interfaces.length - ? ' implements ' + type.interfaces.map(i => i.name).join(' & ') +function printImplementedInterfaces(implementations: InterfaceImplementation[]): string { + return implementations.length + ? ' implements ' + implementations.map(i => i.interface.name).join(' & ') : ''; } -function printFieldBasedType(kind: string, type: ObjectType | InterfaceType): string { - return `${printDescription(type)}${kind} ${type.name}${printImplementedInterfaces(type)}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +function printFieldBasedTypeDefinitionOrExtension(kind: string, type: ObjectType | InterfaceType, extension?: Extension): string | undefined { + const directives = forExtension(type.appliedDirectives, extension); + const interfaces = forExtension([...type.interfaceImplementations()], extension); + const fields = forExtension([...type.fields.values()], extension); + if (!directives.length && !interfaces.length && !fields.length) { + return undefined; + } + return printDescription(type) + + printIsExtension(extension) + + kind + ' ' + type + + printImplementedInterfaces(interfaces) + + printAppliedDirectives(directives) + + printFields(fields); } -function printUnionType(type: UnionType): string { - const possibleTypes = type.types.length ? ' = ' + type.types.join(' | ') : ''; - return `${printDescription(type)}union ${type}${printAppliedDirectives(type)}${possibleTypes}`; +function printUnionDefinitionOrExtension(type: UnionType, extension?: Extension): string | undefined { + const directives = forExtension(type.appliedDirectives, extension); + const members = forExtension([...type.members()], extension); + if (!directives.length && !members.length) { + return undefined; + } + const possibleTypes = members.length ? ' = ' + members.map(m => m.type).join(' | ') : ''; + return printDescription(type) + + printIsExtension(extension) + + 'union ' + type + + printAppliedDirectives(directives) + + possibleTypes; } -function printEnumType(type: EnumType): string { - const vals = type.values.map(v => `${v}${printAppliedDirectives(v)}`); - return `${printDescription(type)}enum ${type}${printAppliedDirectives(type)}${printBlock(vals)}`; +function printEnumDefinitionOrExtension(type: EnumType, extension?: Extension): string | undefined { + const directives = forExtension(type.appliedDirectives, extension); + const values = forExtension(type.values, extension); + if (!directives.length && !values.length) { + return undefined; + } + const vals = values.map(v => `${v}${printAppliedDirectives(v.appliedDirectives)}`); + return printDescription(type) + + printIsExtension(extension) + + 'enum ' + type + + printAppliedDirectives(directives) + + printBlock(vals); } -function printInputObjectType(type: InputObjectType): string { - return `${printDescription(type)}input ${type.name}${printAppliedDirectives(type)}` + printFields([...type.fields.values()]); +function printInputDefinitionOrExtension(type: InputObjectType, extension?: Extension): string | undefined { + const directives = forExtension(type.appliedDirectives, extension); + const fields = forExtension([...type.fields.values()], extension); + if (!directives.length && !fields.length) { + return undefined; + } + return printDescription(type) + + printIsExtension(extension) + + 'input ' + type + + printAppliedDirectives(directives) + + printFields(fields); } function printFields(fields: (FieldDefinition | InputFieldDefinition)[]): string { - return printBlock(fields.map((f, i) => printDescription(f, indent, !i) + indent + `${printField(f)}${printAppliedDirectives(f)}`)); + return printBlock(fields.map((f, i) => printDescription(f, indent, !i) + indent + printField(f) + printAppliedDirectives(f.appliedDirectives))); } function printField(field: FieldDefinition | InputFieldDefinition): string { @@ -157,7 +230,7 @@ function printArgs(args: ArgumentDefinition[], indentation = '') { } function printArg(arg: ArgumentDefinition) { - return `${arg}${printAppliedDirectives(arg)}`; + return `${arg}${printAppliedDirectives(arg.appliedDirectives)}`; } function printBlock(items: string[]): string { From 947cf70c8685d258814abff4a00ce00d3ecde582 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Tue, 22 Jun 2021 14:57:55 +0200 Subject: [PATCH 18/22] Add some printing options --- core-js/src/__tests__/definitions.test.ts | 53 ++++++--- core-js/src/print.ts | 135 ++++++++++++++-------- 2 files changed, 127 insertions(+), 61 deletions(-) diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 5c2637086..31db374d1 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -8,7 +8,7 @@ import { SchemaElement, UnionType } from '../../dist/definitions'; -import { printSchema } from '../../dist/print'; +import { defaultOptions, printSchema } from '../../dist/print'; import { buildSchema } from '../../dist/buildSchema'; import { federationBuiltIns } from '../../dist/federation'; @@ -219,6 +219,10 @@ test('removal of all directives of a schema', () => { directive @bar on ARGUMENT_DEFINITION + type Query { + a(id: String): A + } + type A { a1: String a2: [Int] @@ -228,10 +232,6 @@ test('removal of all directives of a schema', () => { b: String } - type Query { - a(id: String): A - } - union U = A | B`); }); @@ -275,13 +275,14 @@ test('removal of all inacessible elements of a schema', () => { directive @bar on ARGUMENT_DEFINITION + type Query { + a(id: String @bar): A + } + type A { a2: [Int] } - - type Query { - a(id: String @bar): A - }`); + `); }); test('handling of interfaces', () => { @@ -346,15 +347,15 @@ test('handling of interfaces', () => { b.remove(); expect(printSchema(schema)).toMatchString(` + type Query { + bestIs: [I!]! + } + interface I { a: Int b: String } - type Query { - bestIs: [I!]! - } - type T1 implements I { a: Int b: String @@ -511,4 +512,30 @@ test('handling of extensions', () => { expectUnionType(aunion); expect([...aunion.types()].map(t => t.name)).toEqual(['AType', 'AType2', 'AType3']); + expect(printSchema(schema, { ...defaultOptions, mergeTypesAndExtensions: true })).toMatchString(` + schema { + query: AType + } + + interface AInterface @deprecated { + i1: Int + } + + scalar AScalar @deprecated + + type AType { + t1: Int + t2: String + } + + type AType2 { + t1: String + } + + type AType3 { + t2: Int + } + + union AUnion = AType | AType2 | AType3 + `); }); diff --git a/core-js/src/print.ts b/core-js/src/print.ts index f27f68260..9e28fb61d 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -1,6 +1,6 @@ import { ArgumentDefinition, - Directive, + Directive, DirectiveDefinition, EnumType, ExtendableElement, @@ -19,61 +19,93 @@ import { UnionType } from "./definitions"; -const indent = " "; // Could be made an option at some point - -export function printSchema(schema: Schema): string { - const directives = [...schema.directives()]; - const types = [...schema.types()] - .sort((type1, type2) => type1.name.localeCompare(type2.name)); - return ( - printSchemaDefinitionAndExtensions(schema.schemaDefinition) - .concat( - directives.map(directive => printDirectiveDefinition(directive)), - types.flatMap(type => printTypeDefinitionAndExtensions(type)), - ) - .filter(Boolean) - .join('\n\n') - ); +export type Options = { + indentString: string; + definitionsOrder: ('schema' | 'types' | 'directives')[], + typeCompareFn?: (t1: NamedType, t2: NamedType) => number; + directiveCompareFn?: (d1: DirectiveDefinition, d2: DirectiveDefinition) => number; + mergeTypesAndExtensions: boolean } -function definitionAndExtensions(element: {extensions(): ReadonlySet>}) { - return [undefined, ...element.extensions()]; +export const defaultOptions: Options = { + indentString: " ", + definitionsOrder: ['schema', 'directives', 'types'], + mergeTypesAndExtensions: false } -function printSchemaDefinitionAndExtensions(schemaDefinition: SchemaDefinition): string[] { +function isDefinitionOrderValid(options: Options): boolean { + return options.definitionsOrder.length === 3 + && options.definitionsOrder.indexOf('schema') >= 0 + && options.definitionsOrder.indexOf('types') >= 0 + && options.definitionsOrder.indexOf('directives') >= 0; +} + +function validateOptions(options: Options) { + if (!isDefinitionOrderValid(options)) { + throw new Error(`'definitionsOrder' should be a 3-element array containing 'schema', 'types' and 'directives' in the desired order (got: [${options.definitionsOrder.join(', ')}])`); + } +} + +export function printSchema(schema: Schema, options: Options = defaultOptions): string { + validateOptions(options); + let directives = [...schema.directives()]; + if (options.directiveCompareFn) { + directives = directives.sort(options.directiveCompareFn); + } + let types = [...schema.types()]; + if (options.typeCompareFn) { + types = types.sort(options.typeCompareFn); + } + const definitions: string[][] = new Array(3); + definitions[options.definitionsOrder.indexOf('schema')] = printSchemaDefinitionAndExtensions(schema.schemaDefinition, options); + definitions[options.definitionsOrder.indexOf('directives')] = directives.map(directive => printDirectiveDefinition(directive)); + definitions[options.definitionsOrder.indexOf('types')] = types.flatMap(type => printTypeDefinitionAndExtensions(type, options)); + return definitions.flat().join('\n\n'); +} + +function definitionAndExtensions(element: {extensions(): ReadonlySet>}, options: Options): (Extension | null | undefined)[] { + return options.mergeTypesAndExtensions ? [undefined] : [null, ...element.extensions()]; +} + +function printSchemaDefinitionAndExtensions(schemaDefinition: SchemaDefinition, options: Options): string[] { if (isSchemaOfCommonNames(schemaDefinition)) { return []; } - return printDefinitionAndExtensions(schemaDefinition, printSchemaDefinitionOrExtension); + return printDefinitionAndExtensions(schemaDefinition, options, printSchemaDefinitionOrExtension); } function printDefinitionAndExtensions>}>( t: T, - printer: (t: T, extension?: Extension) => string | undefined + options: Options, + printer: (t: T, options: Options, extension?: Extension | null) => string | undefined ): string[] { - return definitionAndExtensions(t) - .map(ext => printer(t, ext)) + return definitionAndExtensions(t, options) + .map(ext => printer(t, options, ext)) .filter(v => v !== undefined) as string[]; } -function printIsExtension(extension?: Extension): string { +function printIsExtension(extension?: Extension | null): string { return extension ? 'extend ' : ''; } -function forExtension | undefined}>(ts: readonly T[], extension?: Extension): T[] { - return ts.filter(r => r.ofExtension() === extension); +function forExtension | undefined}>(ts: readonly T[], extension?: Extension | null): readonly T[] { + if (extension === undefined) { + return ts; + } + return ts.filter(r => (r.ofExtension() ?? null) === extension); } function printSchemaDefinitionOrExtension( schemaDefinition: SchemaDefinition, - extension?: Extension + options: Options, + extension?: Extension | null ): string | undefined { const roots = forExtension([...schemaDefinition.roots()], extension); const directives = forExtension(schemaDefinition.appliedDirectives, extension); if (!roots.length && !directives.length) { return undefined; } - const rootEntries = roots.map((rootType) => `${indent}${rootType.rootKind}: ${rootType.type}`); + const rootEntries = roots.map((rootType) => `${options.indentString}${rootType.rootKind}: ${rootType.type}`); return printDescription(schemaDefinition) + printIsExtension(extension) + 'schema' @@ -97,14 +129,14 @@ function isSchemaOfCommonNames(schema: SchemaDefinition): boolean { return schema.appliedDirectives.length === 0 && !schema.description && [...schema.roots()].every(r => r.isDefaultRootName()); } -export function printTypeDefinitionAndExtensions(type: NamedType): string[] { +export function printTypeDefinitionAndExtensions(type: NamedType, options: Options): string[] { switch (type.kind) { - case 'ScalarType': return printDefinitionAndExtensions(type, printScalarDefinitionOrExtension); - case 'ObjectType': return printDefinitionAndExtensions(type, (t, ext) => printFieldBasedTypeDefinitionOrExtension('type', t, ext)); - case 'InterfaceType': return printDefinitionAndExtensions(type, (t, ext) => printFieldBasedTypeDefinitionOrExtension('interface', t, ext)); - case 'UnionType': return printDefinitionAndExtensions(type, printUnionDefinitionOrExtension); - case 'EnumType': return printDefinitionAndExtensions(type, printEnumDefinitionOrExtension); - case 'InputObjectType': return printDefinitionAndExtensions(type, printInputDefinitionOrExtension); + case 'ScalarType': return printDefinitionAndExtensions(type, options, printScalarDefinitionOrExtension); + case 'ObjectType': return printDefinitionAndExtensions(type, options, (t, options, ext) => printFieldBasedTypeDefinitionOrExtension('type', t, options, ext)); + case 'InterfaceType': return printDefinitionAndExtensions(type, options, (t, options, ext) => printFieldBasedTypeDefinitionOrExtension('interface', t, options, ext)); + case 'UnionType': return printDefinitionAndExtensions(type, options, printUnionDefinitionOrExtension); + case 'EnumType': return printDefinitionAndExtensions(type, options, printEnumDefinitionOrExtension); + case 'InputObjectType': return printDefinitionAndExtensions(type, options, printInputDefinitionOrExtension); } } @@ -134,7 +166,7 @@ function printDescription( return prefix + blockString.replace(/\n/g, '\n' + indentation) + '\n'; } -function printScalarDefinitionOrExtension(type: ScalarType, extension?: Extension): string | undefined { +function printScalarDefinitionOrExtension(type: ScalarType, _?: Options, extension?: Extension | null): string | undefined { const directives = forExtension(type.appliedDirectives, extension); if (extension && !directives.length) { return undefined; @@ -142,13 +174,13 @@ function printScalarDefinitionOrExtension(type: ScalarType, extension?: Extensio return `${printDescription(type)}${printIsExtension(extension)}scalar ${type.name}${printAppliedDirectives(directives)}` } -function printImplementedInterfaces(implementations: InterfaceImplementation[]): string { +function printImplementedInterfaces(implementations: readonly InterfaceImplementation[]): string { return implementations.length ? ' implements ' + implementations.map(i => i.interface.name).join(' & ') : ''; } -function printFieldBasedTypeDefinitionOrExtension(kind: string, type: ObjectType | InterfaceType, extension?: Extension): string | undefined { +function printFieldBasedTypeDefinitionOrExtension(kind: string, type: ObjectType | InterfaceType, options: Options, extension?: Extension | null): string | undefined { const directives = forExtension(type.appliedDirectives, extension); const interfaces = forExtension([...type.interfaceImplementations()], extension); const fields = forExtension([...type.fields.values()], extension); @@ -160,10 +192,10 @@ function printFieldBasedTypeDefinitionOrExtension(kind: string, type: ObjectType + kind + ' ' + type + printImplementedInterfaces(interfaces) + printAppliedDirectives(directives) - + printFields(fields); + + printFields(fields, options); } -function printUnionDefinitionOrExtension(type: UnionType, extension?: Extension): string | undefined { +function printUnionDefinitionOrExtension(type: UnionType, _?: Options, extension?: Extension | null): string | undefined { const directives = forExtension(type.appliedDirectives, extension); const members = forExtension([...type.members()], extension); if (!directives.length && !members.length) { @@ -177,13 +209,16 @@ function printUnionDefinitionOrExtension(type: UnionType, extension?: Extension< + possibleTypes; } -function printEnumDefinitionOrExtension(type: EnumType, extension?: Extension): string | undefined { +function printEnumDefinitionOrExtension(type: EnumType, options: Options, extension?: Extension | null): string | undefined { const directives = forExtension(type.appliedDirectives, extension); const values = forExtension(type.values, extension); if (!directives.length && !values.length) { return undefined; } - const vals = values.map(v => `${v}${printAppliedDirectives(v.appliedDirectives)}`); + const vals = values.map((v, i) => + printDescription(v, options.indentString, !i) + + v + + printAppliedDirectives(v.appliedDirectives)); return printDescription(type) + printIsExtension(extension) + 'enum ' + type @@ -191,7 +226,7 @@ function printEnumDefinitionOrExtension(type: EnumType, extension?: Extension): string | undefined { +function printInputDefinitionOrExtension(type: InputObjectType, options: Options, extension?: Extension | null): string | undefined { const directives = forExtension(type.appliedDirectives, extension); const fields = forExtension([...type.fields.values()], extension); if (!directives.length && !fields.length) { @@ -201,15 +236,19 @@ function printInputDefinitionOrExtension(type: InputObjectType, extension?: Exte + printIsExtension(extension) + 'input ' + type + printAppliedDirectives(directives) - + printFields(fields); + + printFields(fields, options); } -function printFields(fields: (FieldDefinition | InputFieldDefinition)[]): string { - return printBlock(fields.map((f, i) => printDescription(f, indent, !i) + indent + printField(f) + printAppliedDirectives(f.appliedDirectives))); +function printFields(fields: readonly (FieldDefinition | InputFieldDefinition)[], options: Options): string { + return printBlock(fields.map((f, i) => + printDescription(f, options.indentString, !i) + + options.indentString + + printField(f, options) + + printAppliedDirectives(f.appliedDirectives))); } -function printField(field: FieldDefinition | InputFieldDefinition): string { - let args = field.kind == 'FieldDefinition' ? printArgs([...field.arguments.values()], indent) : ''; +function printField(field: FieldDefinition | InputFieldDefinition, options: Options): string { + let args = field.kind == 'FieldDefinition' ? printArgs([...field.arguments.values()], options.indentString) : ''; return `${field.name}${args}: ${field.type}`; } From 15f8df8ea0fbecfa6a3ba284712bcfc0267b349f Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Tue, 22 Jun 2021 15:07:23 +0200 Subject: [PATCH 19/22] Ensure sub-parts of built-in are protected against modification --- core-js/src/definitions.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index be83aab06..c246c410e 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -214,10 +214,17 @@ export abstract class SchemaElement | Schema> } protected checkUpdate(addedElement?: { schema(): Schema | undefined }) { - if (this.isElementBuiltIn() && !Schema.prototype['canModifyBuiltIn'].call(this.schema()!)) { - throw buildError(`Cannot modify built-in ${this}`); - } 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) { + if (thisElement.isElementBuiltIn()) { + throw buildError(`Cannot modify built-in (or part of built-in) ${this}`); + } + thisElement = thisElement.parent; + } + } if (addedElement) { const thatSchema = addedElement.schema(); if (thatSchema && thatSchema != this.schema()) { From 0dd06f00831da342c5731b903dfc15000586ebbb Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Tue, 22 Jun 2021 18:31:05 +0200 Subject: [PATCH 20/22] Improve/fix handling of default values for directive applications --- core-js/package.json | 5 +- core-js/src/__tests__/definitions.test.ts | 40 +++++++++ core-js/src/buildSchema.ts | 1 + core-js/src/definitions.ts | 74 ++++++++++++----- core-js/src/print.ts | 6 +- core-js/src/suggestions.ts | 51 ++++++++++++ core-js/src/values.ts | 99 +++++++++++++++++++++++ package-lock.json | 33 +++++++- 8 files changed, 287 insertions(+), 22 deletions(-) create mode 100644 core-js/src/suggestions.ts create mode 100644 core-js/src/values.ts diff --git a/core-js/package.json b/core-js/package.json index 8c08b161a..eff64aba2 100644 --- a/core-js/package.json +++ b/core-js/package.json @@ -24,12 +24,15 @@ }, "dependencies": { "@types/jest": "^26.0.23", - "deep-equal": "^2.0.5" + "js-levenshtein": "^1.1.6" }, "publishConfig": { "access": "public" }, "peerDependencies": { "graphql": "^14.5.0 || ^15.0.0" + }, + "devDependencies": { + "@types/js-levenshtein": "^1.1.0" } } diff --git a/core-js/src/__tests__/definitions.test.ts b/core-js/src/__tests__/definitions.test.ts index 31db374d1..859bf4585 100644 --- a/core-js/src/__tests__/definitions.test.ts +++ b/core-js/src/__tests__/definitions.test.ts @@ -539,3 +539,43 @@ test('handling of extensions', () => { union AUnion = AType | AType2 | AType3 `); }); + +test('default arguments for directives', () => { + const sdl = ` + directive @Example(inputObject: ExampleInputObject! = {}) on FIELD_DEFINITION + + type Query { + v1: Int @Example + v2: Int @Example(inputObject: {}) + v3: Int @Example(inputObject: {number: 3}) + } + + input ExampleInputObject { + number: Int! = 3 + } + `; + + const schema = buildSchema(sdl); + expect(printSchema(schema)).toMatchString(sdl); + + const query = schema.schemaDefinition.root('query')!.type; + const exampleDirective = schema.directive('Example')!; + expect(query).toHaveField('v1'); + expect(query).toHaveField('v2'); + expect(query).toHaveField('v3'); + const v1 = query.field('v1')!; + const v2 = query.field('v2')!; + const v3 = query.field('v3')!; + + const d1 = v1.appliedDirectivesOf(exampleDirective)[0]; + const d2 = v2.appliedDirectivesOf(exampleDirective)[0]; + const d3 = v3.appliedDirectivesOf(exampleDirective)[0]; + + expect(d1.arguments()).toEqual({}); + expect(d2.arguments()).toEqual({ inputObject: {}}); + expect(d3.arguments()).toEqual({ inputObject: { number: 3 }}); + + expect(d1.arguments(true)).toEqual({ inputObject: { number: 3 }}); + expect(d2.arguments(true)).toEqual({ inputObject: { number: 3 }}); + expect(d3.arguments(true)).toEqual({ inputObject: { number: 3 }}); +}); diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index f874c8371..67a4bdfeb 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -319,6 +319,7 @@ function buildArgumentDefinitionInner(inputNode: InputValueDefinitionNode, arg: function buildInputFieldDefinitionInner(fieldNode: InputValueDefinitionNode, field: InputFieldDefinition) { const type = buildWrapperTypeOrTypeRef(fieldNode.type, field.schema()!); field.type = ensureInputType(type, fieldNode.type); + field.defaultValue = buildValue(fieldNode.defaultValue); buildAppliedDirectives(fieldNode, field); field.description = fieldNode.description?.value; field.sourceAST = fieldNode; diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index c246c410e..98509b4dc 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -5,13 +5,29 @@ import { GraphQLError } from "graphql"; import { assert } from "./utils"; -import deepEqual from 'deep-equal'; +import { withDefaultValues, valueEquals, valueToString } from "./values"; export type QueryRoot = 'query'; export type MutationRoot = 'mutation'; export type SubscriptionRoot = 'subscription'; export type SchemaRoot = QueryRoot | MutationRoot | SubscriptionRoot; +export function defaultRootName(rootKind: SchemaRoot): string { + return rootKind.charAt(0).toUpperCase() + rootKind.slice(1); +} + +function checkDefaultSchemaRoot(type: NamedType): SchemaRoot | 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 type Type = NamedType | WrapperType; export type NamedType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | InputObjectType; export type OutputType = ScalarType | ObjectType | InterfaceType | UnionType | EnumType | ListType | NonNullType; @@ -32,7 +48,19 @@ export function isNamedType(type: Type): type is NamedType { } export function isWrapperType(type: Type): type is WrapperType { - return type.kind == 'ListType' || type.kind == 'NonNullType'; + 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 isInputObjectType(type: Type): type is InputObjectType { + return type.kind == 'InputObjectType'; } export function isOutputType(type: Type): type is OutputType { @@ -555,6 +583,13 @@ export class Schema { 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); + } return type; } @@ -635,7 +670,7 @@ export class RootType extends BaseExtensionMember { } isDefaultRootName() { - return this.rootKind.charAt(0).toUpperCase() + this.rootKind.slice(1) == this.type.name; + return defaultRootName(this.rootKind) == this.type.name; } @@ -1167,6 +1202,7 @@ export class FieldDefinition extends export class InputFieldDefinition extends BaseNamedElementWithType { readonly kind = 'InputFieldDefinition' as const; private _extension?: Extension; + defaultValue?: any get coordinate(): string { const parent = this.parent; @@ -1279,7 +1315,10 @@ export class EnumValue extends NamedSchemaElement { EnumType.prototype['removeValueInternal'].call(this._parent, this); this._parent = undefined; // Enum values have nothing that can reference them outside of their parents - // TODO: that's actually not true if you include arguments (both default value in definition and concrete directive application). + // TODO: that's actually only semi-true if you include arguments, because default values in args and concrete directive applications can + // indirectly refer to enum value. It's indirect though as we currently keep enum value as string in values. That said, it would + // probably be really nice to be able to known if an enum value is used or not, rather then removing it and not knowing if we broke + // something). return []; } @@ -1404,10 +1443,6 @@ export class DirectiveDefinition 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. @@ -1426,8 +1461,19 @@ export class Directive { + if (!includeDefaultValues) { + return this._args; + } + const definition = this.definition; + if (!definition) { + throw buildError(`Cannot include default values for arguments: cannot find directive definition for ${this.name}`); + } + const updated = Object.create(null); + for (const argDef of definition.arguments()) { + updated[argDef.name] = withDefaultValues(this._args[argDef.name], argDef); + } + return updated; } setArguments(args: TArgs) { @@ -1497,14 +1543,6 @@ export class Directive, type: Type) { switch (type.kind) { case 'ListType': diff --git a/core-js/src/print.ts b/core-js/src/print.ts index 9e28fb61d..7a13f832c 100644 --- a/core-js/src/print.ts +++ b/core-js/src/print.ts @@ -18,6 +18,7 @@ import { SchemaElement, UnionType } from "./definitions"; +import { valueToString } from "./values"; export type Options = { indentString: string; @@ -249,7 +250,10 @@ function printFields(fields: readonly (FieldDefinition | InputFieldDefiniti function printField(field: FieldDefinition | InputFieldDefinition, options: Options): string { let args = field.kind == 'FieldDefinition' ? printArgs([...field.arguments.values()], options.indentString) : ''; - return `${field.name}${args}: ${field.type}`; + let defaultValue = field.kind == 'InputFieldDefinition' && field.defaultValue !== undefined + ? ' = ' + valueToString(field.defaultValue) + : ''; + return `${field.name}${args}: ${field.type}${defaultValue}`; } function printArgs(args: ArgumentDefinition[], indentation = '') { diff --git a/core-js/src/suggestions.ts b/core-js/src/suggestions.ts new file mode 100644 index 000000000..f6bb87268 --- /dev/null +++ b/core-js/src/suggestions.ts @@ -0,0 +1,51 @@ +import levenshtein from 'js-levenshtein'; + +/** + * Given an invalid input string and a list of valid options, returns a filtered + * list of valid options sorted based on their similarity with the input. + */ +export function suggestionList(input: string, options: string[]): string[] { + const optionsByDistance = new Map(); + + const threshold = Math.floor(input.length * 0.4) + 1; + const inputLowerCase = input.toLowerCase(); + for (const option of options) { + // Special casing so that if the only mismatch is in uppper/lower-case, then the + // option is always shown. + const distance = inputLowerCase === option.toLowerCase() + ? 1 + : levenshtein(input, option); + if (distance <= threshold) { + optionsByDistance.set(option, distance); + } + } + + return [...optionsByDistance.keys()].sort((a, b) => { + const distanceDiff = optionsByDistance.get(a)! - optionsByDistance.get(b)!; + return distanceDiff !== 0 ? distanceDiff : a.localeCompare(b); + }); +} + +const MAX_SUGGESTIONS = 5; + +/** + * Given [ A, B, C ] return ' Did you mean A, B, or C?'. + */ +export function didYouMean(suggestions: readonly string[]): string { + let message = ' Did you mean '; + + const quotedSuggestions = suggestions.map((x) => `"${x}"`); + switch (suggestions.length) { + case 0: + return ''; + case 1: + return message + quotedSuggestions[0] + '?'; + case 2: + return message + quotedSuggestions[0] + ' or ' + quotedSuggestions[1] + '?'; + } + + const selected = quotedSuggestions.slice(0, MAX_SUGGESTIONS); + const lastItem = selected.pop(); + return message + selected.join(', ') + ', or ' + lastItem + '?'; +} + diff --git a/core-js/src/values.ts b/core-js/src/values.ts new file mode 100644 index 000000000..af62f711f --- /dev/null +++ b/core-js/src/values.ts @@ -0,0 +1,99 @@ +import deepEqual from 'deep-equal'; +import { ArgumentDefinition, InputType, isInputObjectType, isListType, isNonNullType } from './definitions'; +import { GraphQLError } from 'graphql'; +import { didYouMean, suggestionList } from './suggestions'; + +export function valueToString(v: any): string { + if (v === undefined || v === null) { + return "null"; + } + + if (Array.isArray(v)) { + return '[' + v.map(e => valueToString(e)).join(', ') + ']'; + } + + if (typeof v === 'object') { + return '{' + Object.keys(v).map(k => `${k}: ${valueToString(v[k])}`).join(', ') + '}'; + } + + if (typeof v === 'string') { + return JSON.stringify(v); + } + + return String(v); +} + +export function valueEquals(a: any, b: any): boolean { + return deepEqual(a, b); +} + +function buildError(message: string): Error { + // Maybe not the right error for this? + return new Error(message); +} + +export function applyDefaultValues(value: any, type: InputType): any { + if (value === null) { + if (isNonNullType(type)) { + throw new GraphQLError(`Invalid null value for non-null type ${type} while computing default values`); + } + return null; + } + + if (isNonNullType(type)) { + return applyDefaultValues(value, type.ofType); + } + + if (isListType(type)) { + if (Array.isArray(value)) { + return value.map(v => applyDefaultValues(v, type.ofType)); + } else { + return applyDefaultValues(value, type.ofType); + } + } + + if (isInputObjectType(type)) { + if (typeof value !== 'object') { + throw new GraphQLError(`Expected value for type ${type} to be an object, but is ${typeof value}.`); + } + + const updated = Object.create(null); + for (const field of type.fields.values()) { + if (!field.type) { + throw buildError(`Cannot compute default value for field ${field.name} of ${type} as the field type is undefined`); + } + const fieldValue = value[field.name]; + if (fieldValue === undefined) { + if (field.defaultValue !== undefined) { + updated[field.name] = applyDefaultValues(field.defaultValue, field.type); + } else if (isNonNullType(field.type)) { + throw new GraphQLError(`Field "${field.name}" of required type ${type} was not provided.`); + } + } else { + updated[field.name] = applyDefaultValues(fieldValue, field.type); + } + } + + // Ensure every provided field is defined. + for (const fieldName of Object.keys(value)) { + if (!type.fields.has(fieldName)) { + const suggestions = suggestionList(fieldName, [...type.fields.keys()]); + throw new GraphQLError(`Field "${fieldName}" is not defined by type "${type}".` + didYouMean(suggestions)); + } + } + return updated; + } + return value; +} + +export function withDefaultValues(value: any, argument: ArgumentDefinition): any { + if (!argument.type) { + throw buildError(`Cannot compute default value for argument ${argument} as the type is undefined`); + } + if (value === undefined) { + if (argument.defaultValue) { + return applyDefaultValues(argument.defaultValue ?? null, argument.type); + } + } + return applyDefaultValues(value, argument.type); +} diff --git a/package-lock.json b/package-lock.json index d8fec4f1d..ba620fea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,10 @@ "license": "MIT", "dependencies": { "@types/jest": "^26.0.23", - "deep-equal": "^2.0.5" + "js-levenshtein": "^1.1.6" + }, + "devDependencies": { + "@types/js-levenshtein": "^1.1.0" }, "engines": { "node": ">=12.13.0 <17.0" @@ -6093,6 +6096,12 @@ "pretty-format": "^26.0.0" } }, + "node_modules/@types/js-levenshtein": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz", + "integrity": "sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ==", + "dev": true + }, "node_modules/@types/js-yaml": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.1.tgz", @@ -13040,6 +13049,14 @@ "node": ">= 10.14.2" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -19952,7 +19969,8 @@ "version": "file:core-js", "requires": { "@types/jest": "^26.0.23", - "deep-equal": "^2.0.5" + "@types/js-levenshtein": "^1.1.0", + "js-levenshtein": "^1.1.6" } }, "@apollo/federation": { @@ -25286,6 +25304,12 @@ "pretty-format": "^26.0.0" } }, + "@types/js-levenshtein": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz", + "integrity": "sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ==", + "dev": true + }, "@types/js-yaml": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.1.tgz", @@ -31016,6 +31040,11 @@ "supports-color": "^7.0.0" } }, + "js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", From a5c75cf8f0578eb533bbb10e03543ff11c013fa6 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Wed, 23 Jun 2021 11:11:46 +0200 Subject: [PATCH 21/22] Add exports to core-js && additions --- core-js/src/buildSchema.ts | 4 +-- core-js/src/definitions.ts | 62 ++++++++++++++++++++++---------------- core-js/src/index.ts | 6 ++++ 3 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 core-js/src/index.ts diff --git a/core-js/src/buildSchema.ts b/core-js/src/buildSchema.ts index 67a4bdfeb..5d2955bf7 100644 --- a/core-js/src/buildSchema.ts +++ b/core-js/src/buildSchema.ts @@ -276,7 +276,7 @@ function buildFieldDefinitionInner(fieldNode: FieldDefinitionNode, field: FieldD field.sourceAST = fieldNode; } -export function ensureOutputType(type: Type, node: TypeNode): OutputType { +function ensureOutputType(type: Type, node: TypeNode): OutputType { if (isOutputType(type)) { return type; } else { @@ -284,7 +284,7 @@ export function ensureOutputType(type: Type, node: TypeNode): OutputType { } } -export function ensureInputType(type: Type, node: TypeNode): InputType { +function ensureInputType(type: Type, node: TypeNode): InputType { if (isInputType(type)) { return type; } else { diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 98509b4dc..761571675 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -7,16 +7,18 @@ import { import { assert } from "./utils"; import { withDefaultValues, valueEquals, valueToString } from "./values"; -export type QueryRoot = 'query'; -export type MutationRoot = 'mutation'; -export type SubscriptionRoot = 'subscription'; -export type SchemaRoot = QueryRoot | MutationRoot | SubscriptionRoot; +export type QueryRootKind = 'query'; +export type MutationRootKind = 'mutation'; +export type SubscriptionRootKind = 'subscription'; +export type SchemaRootKind = QueryRootKind | MutationRootKind | SubscriptionRootKind; -export function defaultRootName(rootKind: SchemaRoot): string { +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): SchemaRoot | undefined { +function checkDefaultSchemaRoot(type: NamedType): SchemaRootKind | undefined { if (type.kind !== 'ObjectType') { return undefined; } @@ -47,6 +49,10 @@ export function isNamedType(type: Type): type is NamedType { return type instanceof BaseNamedType; } +export function getNamedType(type: Type): NamedType { + return isNamedType(type) ? type : type.baseType(); +} + export function isWrapperType(type: Type): type is WrapperType { return isListType(type) || isNonNullType(type); } @@ -59,6 +65,26 @@ export function isNonNullType(type: Type): type is NonNullType { return type.kind == 'NonNullType'; } +export function isScalarType(type: Type): type is ScalarType { + return type.kind == 'ObjectType'; +} + +export function isObjectType(type: Type): type is ObjectType { + return type.kind == 'ObjectType'; +} + +export function isInterfaceType(type: Type): type is InterfaceType { + return type.kind == 'ObjectType'; +} + +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'; } @@ -76,14 +102,6 @@ export function isOutputType(type: Type): type is OutputType { } } -export function ensureOutputType(type: Type): OutputType { - if (isOutputType(type)) { - return type; - } else { - throw new Error(`Type ${type} (${type.kind}) is not an output type`); - } -} - export function isInputType(type: Type): type is InputType { switch (baseType(type).kind) { case 'ScalarType': @@ -95,14 +113,6 @@ export function isInputType(type: Type): type is InputType { } } -export function ensureInputType(type: Type): InputType { - if (isInputType(type)) { - return type; - } else { - throw new Error(`Type ${type} (${type.kind}) is not an input type`); - } -} - export function baseType(type: Type): NamedType { return isWrapperType(type) ? type.baseType() : type; } @@ -665,7 +675,7 @@ export class Schema { } export class RootType extends BaseExtensionMember { - constructor(readonly rootKind: SchemaRoot, readonly type: ObjectType) { + constructor(readonly rootKind: SchemaRootKind, readonly type: ObjectType) { super(); } @@ -681,18 +691,18 @@ export class RootType extends BaseExtensionMember { export class SchemaDefinition extends SchemaElement { readonly kind = 'SchemaDefinition' as const; - protected readonly _roots: Map = new Map(); + protected readonly _roots: Map = new Map(); protected readonly _extensions: Set> = new Set(); *roots(): Generator { yield* this._roots.values(); } - root(rootKind: SchemaRoot): RootType | undefined { + root(rootKind: SchemaRootKind): RootType | undefined { return this._roots.get(rootKind); } - setRoot(rootKind: SchemaRoot, nameOrType: ObjectType | string): RootType { + setRoot(rootKind: SchemaRootKind, nameOrType: ObjectType | string): RootType { let toSet: RootType; if (typeof nameOrType === 'string') { this.checkUpdate(); diff --git a/core-js/src/index.ts b/core-js/src/index.ts new file mode 100644 index 000000000..f07508106 --- /dev/null +++ b/core-js/src/index.ts @@ -0,0 +1,6 @@ +export * from './definitions'; +export * from './buildSchema'; +export * from './print'; +export * from './values'; +export * from './federation'; +export * from './utils'; From f1e829ce96f9cd21d16ff3516e0a3be94d4855e5 Mon Sep 17 00:00:00 2001 From: Sylvain Lebresne Date: Wed, 23 Jun 2021 13:53:26 +0200 Subject: [PATCH 22/22] Fix typo --- core-js/src/definitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-js/src/definitions.ts b/core-js/src/definitions.ts index 761571675..2c02fb59d 100644 --- a/core-js/src/definitions.ts +++ b/core-js/src/definitions.ts @@ -74,7 +74,7 @@ export function isObjectType(type: Type): type is ObjectType { } export function isInterfaceType(type: Type): type is InterfaceType { - return type.kind == 'ObjectType'; + return type.kind == 'InterfaceType'; } export function isEnumType(type: Type): type is EnumType {