diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b8be5c30..86355ad8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -20,6 +20,7 @@ jobs: run: yarn -s test:ci - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 + if: matrix.os == 'ubuntu-latest' && matrix.node-version == '10.x' with: directory: ./coverage diff --git a/.github/workflows/trunk.yml b/.github/workflows/trunk.yml index 7753a1c4..f3a5bae2 100644 --- a/.github/workflows/trunk.yml +++ b/.github/workflows/trunk.yml @@ -21,6 +21,7 @@ jobs: - name: Test run: yarn -s test:ci - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.node-version == '10.x' uses: codecov/codecov-action@v1 with: directory: ./coverage diff --git a/codecov.yml b/codecov.yml index 55a95925..9afc2967 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,5 +1,10 @@ +codecov: + require_ci_to_pass: no + notify: + wait_for_ci: no + comment: - layout: "diff" + layout: 'diff' coverage: status: diff --git a/docs/content/015-api/110-plugins.mdx b/docs/content/015-api/110-plugins.mdx index 076a2d34..8c6c2f4c 100644 --- a/docs/content/015-api/110-plugins.mdx +++ b/docs/content/015-api/110-plugins.mdx @@ -78,6 +78,49 @@ plugin({ }) ``` +### onObjectDefinition(t, objectConfig) + +The "onObjectDefinition" hook is called when an `objectType` is created, and is provided `t`, the object +passed into the `definition` block, as well as the `config` of the object. + +```ts +export const NodePlugin = plugin({ + name: 'NodePlugin', + description: 'Allows us to designate the field used to determine the "node" interface', + objectTypeDefTypes: `node?: string | core.FieldResolver`, + onObjectDefinition(t, { node }) { + if (node) { + let resolveFn + if (typeof node === 'string') { + const fieldResolve: FieldResolver = (root, args, ctx, info) => { + return `${info.parentType.name}:${root[node]}` + } + resolveFn = fieldResolve + } else { + resolveFn = node + } + t.implements('Node') + t.id('id', { + nullable: false, + resolve: resolveFn, + }) + } + }, +}) +``` + +Usage: + +```ts +const User = objectType({ + name: 'User', + node: 'id', // adds `id` field + definition(t) { + t.string('name') + }, +}) +``` + ### onCreateFieldResolver(config) Every ObjectType, whether they are defined via Nexus' `objectType` api, or elsewhere is given a resolver. diff --git a/examples/kitchen-sink/src/example-plugins.ts b/examples/kitchen-sink/src/example-plugins.ts index 5321bea1..cc9b7a77 100644 --- a/examples/kitchen-sink/src/example-plugins.ts +++ b/examples/kitchen-sink/src/example-plugins.ts @@ -1,4 +1,4 @@ -import { plugin } from '@nexus/schema' +import { plugin, interfaceType, FieldResolver } from '@nexus/schema' export const logMutationTimePlugin = plugin({ name: 'LogMutationTime', @@ -15,3 +15,50 @@ export const logMutationTimePlugin = plugin({ } }, }) + +export const NodePlugin = plugin({ + name: 'NodePlugin', + description: 'Allows us to designate the field used to ', + objectTypeDefTypes: `node?: string | core.FieldResolver`, + onObjectDefinition(t, { node }) { + if (node) { + let resolveFn + if (typeof node === 'string') { + const fieldResolve: FieldResolver = (root, args, ctx, info) => { + return `${info.parentType.name}:${root[node]}` + } + resolveFn = fieldResolve + } else { + resolveFn = node + } + t.implements('Node') + t.id('id', { + nullable: false, + resolve: resolveFn, + }) + } + }, + onMissingType(t, builder) { + if (t === 'Node') { + return interfaceType({ + name: 'Node', + description: + 'A "Node" is a field with a required ID field (id), per the https://relay.dev/docs/en/graphql-server-specification', + definition(t) { + t.id('id', { + nullable: false, + resolve: () => { + throw new Error('Abstract') + }, + }) + t.resolveType((t) => { + if (t.__typename) { + return t.__typename + } + throw new Error('__typename missing for resolving Node') + }) + }, + }) + } + }, +}) diff --git a/examples/kitchen-sink/src/index.ts b/examples/kitchen-sink/src/index.ts index 0c58d6c6..5840951b 100644 --- a/examples/kitchen-sink/src/index.ts +++ b/examples/kitchen-sink/src/index.ts @@ -10,7 +10,7 @@ import { ApolloServer } from 'apollo-server' import { separateOperations } from 'graphql' import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity' import path from 'path' -import { logMutationTimePlugin } from './example-plugins' +import { logMutationTimePlugin, NodePlugin } from './example-plugins' import * as types from './kitchen-sink-definitions' const DEBUGGING_CURSOR = false @@ -24,6 +24,7 @@ const schema = makeSchema({ typegen: path.join(__dirname, './kitchen-sink.gen.ts'), }, plugins: [ + NodePlugin, connectionPlugin({ encodeCursor: fn, decodeCursor: fn, diff --git a/examples/kitchen-sink/src/kitchen-sink-definitions.ts b/examples/kitchen-sink/src/kitchen-sink-definitions.ts index 1f114bfa..f4c10f6e 100644 --- a/examples/kitchen-sink/src/kitchen-sink-definitions.ts +++ b/examples/kitchen-sink/src/kitchen-sink-definitions.ts @@ -31,18 +31,6 @@ export const testArgs2 = { bar: idArg(), } -export const Node = interfaceType({ - name: 'Node', - definition(t) { - t.id('id', { - nullable: false, - resolve: () => { - throw new Error('Abstract') - }, - }) - }, -}) - export const Mutation = mutationType({ definition(t) { t.boolean('ok', () => true) @@ -118,6 +106,7 @@ export const TestUnion = unionType({ export const TestObj = objectType({ name: 'TestObj', + node: (obj) => `TestObj:${obj.item}`, definition(t) { t.implements('Bar', Baz) t.string('item') diff --git a/examples/ts-ast-reader/ts-ast-reader-schema.graphql b/examples/ts-ast-reader/ts-ast-reader-schema.graphql index ec6a2c50..34592f35 100644 --- a/examples/ts-ast-reader/ts-ast-reader-schema.graphql +++ b/examples/ts-ast-reader/ts-ast-reader-schema.graphql @@ -613,6 +613,7 @@ enum NodeFlags { Namespace NestedNamespace None + OptionalChain PermanentlySetIncrementalFlags PossiblyContainsDynamicImport PossiblyContainsImportMeta @@ -621,6 +622,7 @@ enum NodeFlags { Synthesized ThisNodeHasError ThisNodeOrAnySubNodesHasError + TypeCached TypeExcludesFlags UNKNOWN YieldContext @@ -842,6 +844,7 @@ enum SyntaxKind { ArrowFunction AsExpression AsKeyword + AssertsKeyword AsteriskAsteriskEqualsToken AsteriskAsteriskToken AsteriskEqualsToken @@ -942,6 +945,7 @@ enum SyntaxKind { FirstNode FirstPunctuation FirstReservedWord + FirstStatement FirstTemplateToken FirstToken FirstTriviaToken @@ -994,12 +998,17 @@ enum SyntaxKind { JSDocComment JSDocEnumTag JSDocFunctionType + JSDocImplementsTag JSDocNamepathType JSDocNonNullableType JSDocNullableType JSDocOptionalType JSDocParameterTag + JSDocPrivateTag JSDocPropertyTag + JSDocProtectedTag + JSDocPublicTag + JSDocReadonlyTag JSDocReturnTag JSDocSignature JSDocTag @@ -1037,6 +1046,7 @@ enum SyntaxKind { LastLiteralToken LastPunctuation LastReservedWord + LastStatement LastTemplateToken LastToken LastTriviaToken @@ -1063,6 +1073,7 @@ enum SyntaxKind { MultiLineCommentTrivia NamedExports NamedImports + NamespaceExport NamespaceExportDeclaration NamespaceImport NamespaceKeyword @@ -1097,6 +1108,7 @@ enum SyntaxKind { PlusToken PostfixUnaryExpression PrefixUnaryExpression + PrivateIdentifier PrivateKeyword PropertyAccessExpression PropertyAssignment @@ -1105,6 +1117,8 @@ enum SyntaxKind { ProtectedKeyword PublicKeyword QualifiedName + QuestionDotToken + QuestionQuestionToken QuestionToken ReadonlyKeyword RegularExpressionLiteral @@ -1133,6 +1147,7 @@ enum SyntaxKind { SymbolKeyword SyntaxList SyntheticExpression + SyntheticReferenceExpression TaggedTemplateExpression TemplateExpression TemplateHead diff --git a/src/builder.ts b/src/builder.ts index 30eac51d..8831165e 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -403,6 +403,16 @@ export class SchemaBuilder { */ protected onAfterBuildFns: Exclude[] = [] + /** + * Executed after the object is defined, allowing us to add additional fields to the object + */ + protected onObjectDefinitionFns: Exclude[] = [] + + /** + * Executed after the object is defined, allowing us to add additional fields to the object + */ + protected onInputObjectDefinitionFns: Exclude[] = [] + /** * The `schemaExtension` is created just after the types are walked, * but before the fields are materialized. @@ -673,6 +683,12 @@ export class SchemaBuilder { if (pluginConfig.onAfterBuild) { this.onAfterBuildFns.push(pluginConfig.onAfterBuild) } + if (pluginConfig.onObjectDefinition) { + this.onObjectDefinitionFns.push(pluginConfig.onObjectDefinition) + } + if (pluginConfig.onInputObjectDefinition) { + this.onInputObjectDefinitionFns.push(pluginConfig.onInputObjectDefinition) + } }) } @@ -761,6 +777,9 @@ export class SchemaBuilder { warn: consoleWarn, }) config.definition(definitionBlock) + this.onInputObjectDefinitionFns.forEach((fn) => { + fn(definitionBlock, config) + }) const extensions = this.inputTypeExtendMap[config.name] if (extensions) { extensions.forEach((extension) => { @@ -790,6 +809,9 @@ export class SchemaBuilder { warn: consoleWarn, }) config.definition(definitionBlock) + this.onObjectDefinitionFns.forEach((fn) => { + fn(definitionBlock, config) + }) const extensions = this.typeExtendMap[config.name] if (extensions) { extensions.forEach((extension) => { diff --git a/src/plugin.ts b/src/plugin.ts index aecd9e28..08571efb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -11,6 +11,9 @@ import { } from './definitions/_types' import { NexusSchemaExtension } from './extensions' import { isPromiseLike, PrintedGenTyping, PrintedGenTypingImport, venn } from './utils' +import { NexusObjectTypeConfig, ObjectDefinitionBlock } from './definitions/objectType' +import { InputDefinitionBlock } from './definitions/definitionBlocks' +import { NexusInputObjectTypeConfig } from './definitions/inputObjectType' export { PluginBuilderLens } @@ -89,6 +92,19 @@ export interface PluginConfig { * After the schema is built, provided the Schema to do any final config validation. */ onAfterBuild?: (schema: GraphQLSchema) => void + /** + * Called immediately after the object is defined, allows for using metadata + * to define the shape of the object. + */ + onObjectDefinition?: (block: ObjectDefinitionBlock, objectConfig: NexusObjectTypeConfig) => void + /** + * Called immediately after the input object is defined, allows for using metadata + * to define the shape of the input object + */ + onInputObjectDefinition?: ( + block: InputDefinitionBlock, + objectConfig: NexusInputObjectTypeConfig + ) => void /** * If a type is not defined in the schema, our plugins can register an `onMissingType` handler, * which will intercept the missing type name and give us an opportunity to respond with a valid @@ -206,6 +222,8 @@ function validatePluginConfig(pluginConfig: PluginConfig): void { 'onBeforeBuild', 'onMissingType', 'onAfterBuild', + 'onObjectDefinition', + 'onInputObjectDefinition', ] const validOptionalProps = ['description', 'fieldDefTypes', 'objectTypeDefTypes', ...optionalPropFns] diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 76a5b002..3fedc747 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -1,5 +1,5 @@ -import { buildSchema, graphql, GraphQLSchema, printSchema } from 'graphql' -import { makeSchema, MiddlewareFn, objectType, plugin, queryField } from '../src/core' +import { buildSchema, graphql, GraphQLSchema, printSchema, introspectionFromSchema } from 'graphql' +import { makeSchema, MiddlewareFn, objectType, plugin, queryField, interfaceType } from '../src/core' import { nullabilityGuardPlugin } from '../src/plugins' import { EXAMPLE_SDL } from './_sdl' @@ -203,6 +203,70 @@ describe('plugin', () => { ) expect(calls).toMatchSnapshot() }) + + it('has an onObjectDefinition option, which receives the object metadata', async () => { + // + const schema = makeSchema({ + outputs: false, + types: [ + interfaceType({ + name: 'Node', + definition(t) { + t.id('id', { + nullable: false, + resolve: () => { + throw new Error('Abstract') + }, + }) + t.resolveType((n) => n.__typename) + }, + }), + objectType({ + name: 'AddsNode', + // @ts-ignore + node: 'id', + definition(t) { + t.string('name') + }, + }), + queryField('getNode', { + type: 'Node', + resolve: () => ({ __typename: 'AddsNode', name: 'test', id: 'abc' }), + }), + ], + plugins: [ + plugin({ + name: 'Node', + onObjectDefinition(t, config) { + const node = (config as any).node as any + if (node) { + t.implements('Node') + t.id('id', { + nullable: false, + resolve: (root) => `${config.name}:${root[node]}`, + }) + } + }, + }), + ], + }) + const result = await graphql( + schema, + ` + { + getNode { + __typename + id + ... on AddsNode { + name + } + } + } + ` + ) + expect(result.data?.getNode).toEqual({ __typename: 'AddsNode', id: 'AddsNode:abc', name: 'test' }) + }) + it('has a plugin.completeValue fn which is used to efficiently complete a value which is possibly a promise', async () => { const calls: string[] = [] const testResolve = (name: string) => (): MiddlewareFn => async (root, args, ctx, info, next) => {