diff --git a/docs/blog/2019-05-17-improvements-to-schema-customization/index.md b/docs/blog/2019-05-17-improvements-to-schema-customization/index.md new file mode 100644 index 0000000000000..728672ce38233 --- /dev/null +++ b/docs/blog/2019-05-17-improvements-to-schema-customization/index.md @@ -0,0 +1,134 @@ +--- +title: Improvements to Schema Customization API - Available in Gatsby 2.5.0 +date: 2019-05-17 +author: Mikhail Novikov +tags: + - schema + - graphql +--- + +Today we are releasing further improvements to the schema customization that [we've released in version 2.2.0](/blog/2019-03-18-releasing-new-schema-customization). You can use them with Gatsby 2.4.0. + +It is now possible to indicate to Gatsby, that you want to add a resolver to an explicitly defined fields. Use extensions like `@link` and `@dateformat` to add default arguments or/and resolvers to fields. In addition, when `@dontInfer` is set, Gatsby will no longer run inference for marked type, allowing one to improve performance for large data sets. + +## Summary + +After about a month of testing schema customization both here and in pre-release we determined a couple of issues. The original aim of our schema customisation work was to remove uncertainty in people's schemas when their data changes. + +However, the original design allowed some uncertainties anyway. In addition, it made inference a more heavy process, trading performance for consistency without providing a way to opt out completely. To summarize, the schema customization work released in Gatsby 2.2.0 had the following issues: + +- Resolvers and arguments of fields like Date and File was determined by inferred data +- There was no easy way to use arguments/resolvers to override the above +- Inferrence was run even when `@dontInfer` flag was on +- There was no way to control inference outside of SDL, eg in Type Builders + +Therefore we have some changes to the way we do inferrence. In addition, we are deprecating some of the features introduced in 2.2.0 and will remove them in Gatsby 3. + +## Changes in Gatsby 2.5.0 + +### noDefaultResolvers and inferrence modes + +First of all, we are deprecating `noDefaultResolvers`. It was an argument of `infer` and `dontInfer`. We feel it was confusing and in some cases it didn't even actually add resolvers :). We will support `noDefaultResolvers` until version 3, after which `@infer` behaviour (see below) will become a default and `noDefaultResolvers` will be removed. + +We didn't want to break things, so we keep old default behaviour, even though we think it's not optimal. Add explicit `@infer` and resolver extensions (like `@link`) to fields to be future proof. + +#### Default (deprecated, removed in v3) + +Applies with no `@infer` and no `@dontInfer` on a type. Equals to `@infer(noDefaultResolvers: false)`. + +Type gets all inferred fields added. If type has defined fields of types `Date`, `File` and any other node, and we inferred that they should have resolver options, resolver options will be added to type with a warning. + +#### Strict inference (future default in v3) + +Applies with `@infer` or `@infer(noDefaultResolvers: true)`. + +Type gets all inferred fields added. Existing fields won't automatically get resolvers (use resolver extensions). + +#### No inferrence + +Applies with `@dontInfer` or `@dontInfer(noDefaultResolvers: true)`. + +Inferrence won't run at all. Existing fields won't automatically get resolvers (use resolver extensions). + +#### No new fields with default resolvers (deprecated, removed in v3) + +Applies with `@dontInfer(noDefaultResolvers: false)` + +Inferrence will run, but fields won't be added. If type has defined fields of types `Date`, `File` and any other node, and we inferred that they should have resolvers/args, resolvers/args will be added to type with a warning. + +### Migrating your code + +Here are suggested changes to your code if you are using schema customization already. Your code will work in Gatsby 2.5.0, but those changes will ensure it stays compatible with Gatsby 3.0 + +1. Add resolver directives to fields +2. Add `@infer` or `@dontInfer` to your type if you don't have it already + +```graphql:title=before +type MyType { + date: Date + image: File + authorByEmail: AuthorJson +} +``` + +```graphql:title=after +type MyType @infer { + date: Date @dateformat + image: File @fileByRelativePath + authorByEmail: Author @link +} +``` + +### Resolver extensions + +Add resolver and resolver options (such as arguments) to the given field. There are currently 3 extensions available. + +- `@dateformat` - add date formatting arguments. Accepts `formatString` and + `locale` options that sets the defaults for this field +- `@link` - connect to a different Node. Arguments `by` and `from`, which + define which field to compare to on a remote node and which field to use on + the source node +- `@fileByRelativePath` - connect to a File node. Same arguments. The + difference from link is that this normalizes the relative path to be + relative from the path where source node is found. + +```graphql +type MyType @infer { + date: Date @dateformat(formatString: "DD MMM", locale: "fi") + image: File @fileByRelativePath + authorByEmail: Author @link(by: "email") +} +``` + +### Type Builders and extensions + +You can now apply configuration to type builder types through extension property on them. + +```js +schema.createObjectType({ + name: MyType, + extensions: { + infer: true, + }, + fields: { + date: { + type: "Date", + extensions: { + dateformat: { + formatString: "DD MMM", + locale: "fi", + }, + }, + }, + }, +}) +``` + +## Conclusions + +With these improvements we hope we'll solve most of the issues that people are having with new schema customization. We want more feedback about this from you - please write a message to [schema customization umbrella issue](https://github.com/gatsbyjs/gatsby/issues/12272) if you encounter any problems. We are working on further improvements, like allowing users and plugins to define their own extensions (see [PR #13738](https://github.com/gatsbyjs/gatsby/pull/13738)). + +Useful links: + +- [createTypes Documentation](https://www.gatsbyjs.org/docs/actions/#createTypes) +- [Umbrella issue for schema customization bug reports](https://github.com/gatsbyjs/gatsby/issues/12272) diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 862fd153aeb50..e6c6784e43c5d 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -74,7 +74,7 @@ "glob": "^7.1.1", "got": "8.0.0", "graphql": "^14.1.1", - "graphql-compose": "6.0.3", + "graphql-compose": "^6.3.2", "graphql-playground-middleware-express": "^1.7.10", "hash-mod": "^0.0.5", "invariant": "^2.2.4", diff --git a/packages/gatsby/src/redux/actions.js b/packages/gatsby/src/redux/actions.js index 6849554793533..62e93c754f20e 100644 --- a/packages/gatsby/src/redux/actions.js +++ b/packages/gatsby/src/redux/actions.js @@ -1288,8 +1288,28 @@ import type GatsbyGraphQLType from "../schema/types/type-builders" * with inferred field types, and default field resolvers for `Date` (which * adds formatting options) and `File` (which resolves the field value as * a `relativePath` foreign-key field) are added. This behavior can be - * customised with `@infer` and `@dontInfer` directives, and their - * `noDefaultResolvers` argument. + * customised with `@infer`, `@dontInfer` directives or extensions. Fields + * may be assigned resolver (and other option like args) with additional + * directives. Currently `@dateformat`, `@link` and `@fileByRelativePath` are + * available. + * + * + * Schema customization controls: + * * `@infer` - run inference on the type and add fields that don't exist on the + * defined type to it. + * * `@dontInfer` - don't run any inference on the type + * + * Extensions to add resolver options: + * * `@dateformat` - add date formatting arguments. Accepts `formatString` and + * `locale` options that sets the defaults for this field + * * `@link` - connect to a different Node. Arguments `by` and `from`, which + * define which field to compare to on a remote node and which field to use on + * the source node + * * `@fileByRelativePath` - connect to a File node. Same arguments. The + * difference from link is that this normalizes the relative path to be + * relative from the path where source node is found. + * + * * * @example * exports.sourceNodes = ({ actions }) => { @@ -1298,17 +1318,17 @@ import type GatsbyGraphQLType from "../schema/types/type-builders" * """ * Markdown Node * """ - * type MarkdownRemark implements Node { + * type MarkdownRemark implements Node @infer { * frontmatter: Frontmatter! * } * * """ * Markdown Frontmatter * """ - * type Frontmatter { + * type Frontmatter @infer { * title: String! - * author: AuthorJson! - * date: Date! + * author: AuthorJson! @link + * date: Date! @dateformat * published: Boolean! * tags: [String!]! * } @@ -1317,9 +1337,9 @@ import type GatsbyGraphQLType from "../schema/types/type-builders" * Author information * """ * # Does not include automatically inferred fields - * type AuthorJson implements Node @dontInfer(noFieldResolvers: true) { + * type AuthorJson implements Node @dontInfer { * name: String! - * birthday: Date! # no default resolvers for Date formatting added + * birthday: Date! @dateformat(locale: "ru") * } * ` * createTypes(typeDefs) @@ -1335,6 +1355,9 @@ import type GatsbyGraphQLType from "../schema/types/type-builders" * frontmatter: 'Frontmatter!' * }, * interfaces: ['Node'], + * extensions: { + * infer: true, + * }, * }), * schema.buildObjectType({ * name: 'Frontmatter', @@ -1345,12 +1368,40 @@ import type GatsbyGraphQLType from "../schema/types/type-builders" * return parent.title || '(Untitled)' * } * }, - * author: 'AuthorJson!', - * date: 'Date!', + * author: { + * type: 'AuthorJson' + * extensions: { + * link: {}, + * }, + * } + * date: { + * type: 'Date!' + * extensions: { + * dateformat: {}, + * }, + * }, * published: 'Boolean!', * tags: '[String!]!', * } - * }) + * }), + * schema.buildObjectType({ + * name: 'AuthorJson', + * fields: { + * name: 'String!' + * birthday: { + * type: 'Date!' + * extensions: { + * dateformat: { + * locale: 'ru', + * }, + * }, + * }, + * }, + * interfaces: ['Node'], + * extensions: { + * infer: false, + * }, + * }), * ] * createTypes(typeDefs) * } diff --git a/packages/gatsby/src/schema/__tests__/__snapshots__/kitchen-sink.js.snap b/packages/gatsby/src/schema/__tests__/__snapshots__/kitchen-sink.js.snap index b59190f7bae47..d8ab564253b2e 100644 --- a/packages/gatsby/src/schema/__tests__/__snapshots__/kitchen-sink.js.snap +++ b/packages/gatsby/src/schema/__tests__/__snapshots__/kitchen-sink.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Kichen sink schema test passes kitchen sink query 1`] = ` +exports[`Kitchen sink schema test passes kitchen sink query 1`] = ` Object { "data": Object { - "addResolvers": Array [ + "createResolvers": Array [ Object { "code": "BdiU-TTFP4h", "id": "1685001452849004065", @@ -43,6 +43,7 @@ Object { "_3invalidKey": null, "code": "BShF_8qhtEv", "comment": 0, + "defaultTime": "05 huhtikuu", "id": "1486495736706552111", "idWithDecoration": "decoration-1486495736706552111", "image": Object { @@ -51,6 +52,8 @@ Object { }, }, "likes": 8, + "localeFormat": "05 huhtikuu 2017", + "localeString": "05 апреля", "time": "05.04.2017", }, }, @@ -59,6 +62,7 @@ Object { "_3invalidKey": null, "code": "BY6B8z5lR1F", "comment": 0, + "defaultTime": "11 syyskuu", "id": "1601601194425654597", "idWithDecoration": "decoration-1601601194425654597", "image": Object { @@ -67,6 +71,8 @@ Object { }, }, "likes": 9, + "localeFormat": "11 syyskuu 2017", + "localeString": "11 сентября", "time": "11.09.2017", }, }, diff --git a/packages/gatsby/src/schema/__tests__/fixtures/kitchen-sink.json b/packages/gatsby/src/schema/__tests__/fixtures/kitchen-sink.json index c9ac2f76726e1..0446c7912550b 100644 --- a/packages/gatsby/src/schema/__tests__/fixtures/kitchen-sink.json +++ b/packages/gatsby/src/schema/__tests__/fixtures/kitchen-sink.json @@ -1060,7 +1060,7 @@ "nodeAPIs": [ "createPages", "sourceNodes", - "addResolvers" + "createResolvers" ], "browserAPIs": [ "shouldUpdateScroll", diff --git a/packages/gatsby/src/schema/__tests__/kitchen-sink.js b/packages/gatsby/src/schema/__tests__/kitchen-sink.js index 4b76b33ff20f8..f26a11632fa7d 100644 --- a/packages/gatsby/src/schema/__tests__/kitchen-sink.js +++ b/packages/gatsby/src/schema/__tests__/kitchen-sink.js @@ -1,7 +1,14 @@ // @flow const { SchemaComposer } = require(`graphql-compose`) -const { graphql } = require(`graphql`) +const { + graphql, + GraphQLSchema, + GraphQLNonNull, + GraphQLList, + GraphQLObjectType, + getNamedType, +} = require(`graphql`) const { store } = require(`../../redux`) const { build } = require(`../index`) const fs = require(`fs-extra`) @@ -14,7 +21,7 @@ jest.mock(`../../utils/api-runner-node`) const apiRunnerNode = require(`../../utils/api-runner-node`) // XXX(freiksenet): Expand -describe(`Kichen sink schema test`, () => { +describe(`Kitchen sink schema test`, () => { let schema const runQuery = query => @@ -47,17 +54,20 @@ describe(`Kichen sink schema test`, () => { store.dispatch({ type: `CREATE_TYPES`, payload: ` - type PostsJson implements Node { + type PostsJson implements Node @infer { id: String! - time: Date + time: Date @dateformat(locale: "fi", formatString: "DD MMMM") code: String + image: File @fileByRelativePath } `, }) - store.dispatch({ - type: `ADD_THIRD_PARTY_SCHEMA`, - payload: buildThirdPartySchema(), - }) + buildThirdPartySchemas().forEach(schema => + store.dispatch({ + type: `ADD_THIRD_PARTY_SCHEMA`, + payload: schema, + }) + ) await build({}) schema = store.getState().schema }) @@ -72,6 +82,9 @@ describe(`Kichen sink schema test`, () => { id idWithDecoration time(formatString: "DD.MM.YYYY") + localeString: time(locale: "ru") + localeFormat: time(formatString: "DD MMMM YYYY") + defaultTime: time code likes comment @@ -97,7 +110,7 @@ describe(`Kichen sink schema test`, () => { idWithDecoration likes } - addResolvers: likedEnough { + createResolvers: likedEnough { id likes code @@ -130,9 +143,21 @@ describe(`Kichen sink schema test`, () => { `) ).toMatchSnapshot() }) + + it(`correctly resolves nested Query types from third-party types`, () => { + const queryFields = schema.getQueryType().getFields() + ;[`relay`, `relay2`, `query`, `manyQueries`].forEach(fieldName => + expect(getNamedType(queryFields[fieldName].type)).toBe( + schema.getQueryType() + ) + ) + expect(schema.getType(`Nested`).getFields().query.type).toBe( + schema.getQueryType() + ) + }) }) -const buildThirdPartySchema = () => { +const buildThirdPartySchemas = () => { const schemaComposer = new SchemaComposer() schemaComposer.addTypeDefs(` type ThirdPartyStuff { @@ -205,7 +230,31 @@ const buildThirdPartySchema = () => { schemaComposer.addSchemaMustHaveType( schemaComposer.getOTC(`ThirdPartyStuff3`) ) - return schemaComposer.buildSchema() + + // Query type with non-default name + const RootQueryType = new GraphQLObjectType({ + name: `RootQueryType`, + fields: () => { + return { + query: { type: RootQueryType }, + manyQueries: { + type: new GraphQLNonNull(new GraphQLList(RootQueryType)), + }, + nested: { type: Nested }, + } + }, + }) + const Nested = new GraphQLObjectType({ + name: `Nested`, + fields: () => { + return { + query: { type: RootQueryType }, + } + }, + }) + const schema = new GraphQLSchema({ query: RootQueryType }) + + return [schemaComposer.buildSchema(), schema] } const mockSetFieldsOnGraphQLNodeType = async ({ type: { name } }) => { diff --git a/packages/gatsby/src/schema/__tests__/queries-file.js b/packages/gatsby/src/schema/__tests__/queries-file.js index f6e5a3afe0155..232c197042c34 100644 --- a/packages/gatsby/src/schema/__tests__/queries-file.js +++ b/packages/gatsby/src/schema/__tests__/queries-file.js @@ -143,7 +143,7 @@ describe(`Query fields of type File`, () => { files { name } } arrayOfArray { name } - arrayOfArrayOfObjects { + arrayOfArrayOfObjects { nested { name } diff --git a/packages/gatsby/src/schema/extensions/index.js b/packages/gatsby/src/schema/extensions/index.js new file mode 100644 index 0000000000000..d52d2588b346c --- /dev/null +++ b/packages/gatsby/src/schema/extensions/index.js @@ -0,0 +1,147 @@ +const { + GraphQLBoolean, + GraphQLNonNull, + GraphQLDirective, + GraphQLString, + DirectiveLocation, + defaultFieldResolver, +} = require(`graphql`) + +const { link, fileByPath } = require(`../resolvers`) +const { getDateResolver } = require(`../types/date`) + +// Reserved for internal use +const internalExtensionNames = [`createdFrom`, `directives`, `infer`, `plugin`] + +const typeExtensions = { + infer: { + description: `Infer field types from field values.`, + args: { + noDefaultResolvers: { + type: GraphQLBoolean, + description: `Don't add default resolvers to defined fields.`, + deprecationReason: `noDefaultResolvers is deprecated, annotate individual fields.`, + }, + }, + }, + dontInfer: { + description: `Do not infer field types from field values.`, + args: { + noDefaultResolvers: { + type: GraphQLBoolean, + description: `Don't add default resolvers to defined fields.`, + deprecationReason: `noDefaultResolvers is deprecated, annotate individual fields.`, + }, + }, + }, +} + +const fieldExtensions = { + dateformat: { + description: `Add date formating options.`, + args: { + formatString: { type: GraphQLString }, + locale: { type: GraphQLString }, + }, + process(args, fieldConfig) { + return getDateResolver(args) + }, + }, + + link: { + description: `Link to node by foreign-key relation.`, + args: { + by: { + type: new GraphQLNonNull(GraphQLString), + defaultValue: `id`, + }, + from: { + type: GraphQLString, + }, + }, + process(args, fieldConfig) { + return { + resolve: link(args), + } + }, + }, + + fileByRelativePath: { + description: `Link to File node by relative path.`, + args: { + from: { + type: GraphQLString, + }, + }, + process(args, fieldConfig) { + return { + resolve: fileByPath(args), + } + }, + }, + + proxyFrom: { + description: `Proxy resolver from another field.`, + process(from, fieldConfig) { + const resolver = fieldConfig.resolve || defaultFieldResolver + return { + resolve(source, args, context, info) { + return resolver(source, args, context, { + ...info, + fieldName: from, + }) + }, + } + }, + }, +} + +const toDirectives = ({ extensions, locations }) => + Object.keys(extensions).map(name => { + const extension = extensions[name] + const { args, description } = extension + return new GraphQLDirective({ name, args, description, locations }) + }) + +const addDirectives = ({ schemaComposer }) => { + const fieldDirectives = toDirectives({ + extensions: fieldExtensions, + locations: [DirectiveLocation.FIELD_DEFINITION], + }) + fieldDirectives.forEach(directive => schemaComposer.addDirective(directive)) + const typeDirectives = toDirectives({ + extensions: typeExtensions, + locations: [DirectiveLocation.OBJECT], + }) + typeDirectives.forEach(directive => schemaComposer.addDirective(directive)) +} + +const processFieldExtensions = ({ + schemaComposer, + typeComposer, + parentSpan, +}) => { + typeComposer.getFieldNames().forEach(fieldName => { + const extensions = typeComposer.getFieldExtensions(fieldName) + Object.keys(extensions) + .filter(name => !internalExtensionNames.includes(name)) + .sort(a => a === `proxyFrom`) // Ensure `proxyFrom` is run last + .forEach(name => { + const { process } = fieldExtensions[name] || {} + if (process) { + // Always get fresh field config as it will have been changed + // by previous field extension + const prevFieldConfig = typeComposer.getFieldConfig(fieldName) + typeComposer.extendField( + fieldName, + process(extensions[name], prevFieldConfig) + ) + } + }) + }) +} + +module.exports = { + addDirectives, + processFieldExtensions, +} diff --git a/packages/gatsby/src/schema/infer/__tests__/infer.js b/packages/gatsby/src/schema/infer/__tests__/infer.js index acda4be1bab69..fba26902540ca 100644 --- a/packages/gatsby/src/schema/infer/__tests__/infer.js +++ b/packages/gatsby/src/schema/infer/__tests__/infer.js @@ -320,7 +320,7 @@ describe(`GraphQL type inference`, () => { ` with_space with_hyphen - with_resolver(formatString:"DD.MM.YYYY") + with_resolver(formatString: "DD.MM.YYYY") _123 _456 { testingTypeNameCreation diff --git a/packages/gatsby/src/schema/infer/__tests__/merge-types.js b/packages/gatsby/src/schema/infer/__tests__/merge-types.js index 4327e225929d3..4291ede5bd369 100644 --- a/packages/gatsby/src/schema/infer/__tests__/merge-types.js +++ b/packages/gatsby/src/schema/infer/__tests__/merge-types.js @@ -57,12 +57,17 @@ describe(`merges explicit and inferred type definitions`, () => { ) }) - const buildTestSchemaWithSdl = async ({ - infer = true, - addDefaultResolvers = true, - }) => { - const inferDirective = infer ? `@infer` : `@dontInfer` - const noDefaultResolvers = addDefaultResolvers ? `false` : `true` + const buildTestSchemaWithSdl = async ({ infer, addDefaultResolvers }) => { + let directive = `` + if (infer != null) { + directive = infer ? `@infer` : `@dontInfer` + if (addDefaultResolvers != null) { + directive += `(noDefaultResolvers: ${ + addDefaultResolvers ? `false` : `true` + })` + } + } + const typeDefs = [ ` type NestedNested { @@ -77,7 +82,7 @@ describe(`merges explicit and inferred type definitions`, () => { nested: NestedNested } - type Test implements Node ${inferDirective}(noDefaultResolvers: ${noDefaultResolvers}) { + type Test implements Node ${directive} { explicitDate: Date bar: Boolean! nested: Nested! @@ -102,9 +107,16 @@ describe(`merges explicit and inferred type definitions`, () => { } const buildTestSchemaWithTypeBuilders = async ({ - infer = true, - addDefaultResolvers = true, + infer, + addDefaultResolvers, }) => { + let extensions = {} + if (infer != null) { + extensions.infer = infer + if (addDefaultResolvers != null) { + extensions.addDefaultResolvers = addDefaultResolvers + } + } const typeDefs = [ buildObjectType({ name: `NestedNested`, @@ -125,10 +137,7 @@ describe(`merges explicit and inferred type definitions`, () => { buildObjectType({ name: `Test`, interfaces: [`Node`], - extensions: { - infer, - addDefaultResolvers, - }, + extensions, fields: { explicitDate: `Date`, bar: `Boolean!`, @@ -159,7 +168,7 @@ describe(`merges explicit and inferred type definitions`, () => { [`typeBuilders`, buildTestSchemaWithTypeBuilders], ].forEach(([name, buildTestSchema]) => { describe(`with ${name}`, () => { - it(`with default strategy`, async () => { + it(`with default strategy (implicit "@infer(noDefaultResolvers: false)")`, async () => { const schema = await buildTestSchema({}) const fields = schema.getType(`Test`).getFields() const nestedFields = schema.getType(`Nested`).getFields() @@ -207,7 +216,58 @@ describe(`merges explicit and inferred type definitions`, () => { expect(fields.inferDate.resolve).toBeDefined() }) - it(`with @dontInfer directive`, async () => { + it(`with @infer (implicit "noDefaultResolvers: true")`, async () => { + const schema = await buildTestSchema({ + infer: true, + }) + + const fields = schema.getType(`Test`).getFields() + const nestedFields = schema.getType(`Nested`).getFields() + const nestedNestedFields = schema.getType(`NestedNested`).getFields() + + // Non-conflicting top-level fields added + expect(fields.foo.type.toString()).toBe(`Boolean`) + expect(fields.bar.type.toString()).toBe(`Boolean!`) + + // Non-conflicting fields added on nested type + expect(fields.nested.type.toString()).toBe(`Nested!`) + expect(fields.nestedArray.type.toString()).toBe(`[Nested!]!`) + expect(nestedFields.foo.type.toString()).toBe(`Boolean`) + expect(nestedFields.bar.type.toString()).toBe(`Boolean!`) + expect(nestedNestedFields.foo.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.bar.type.toString()).toBe(`Boolean!`) + + // When type is referenced more than once on typeDefs, all non-conflicting + // fields are added + expect(nestedFields.extra.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.notExtra.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.extraExtra.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.extraExtraExtra.type.toString()).toBe( + `Boolean` + ) + + // Explicit typeDefs have proprity in case of type conflict + expect(fields.conflictType.type.toString()).toBe(`String!`) + expect(fields.conflictArray.type.toString()).toBe(`Int!`) + expect(fields.conflictArrayReverse.type.toString()).toBe(`[Int!]!`) + expect(fields.conflictArrayType.type.toString()).toBe(`[String!]!`) + expect(fields.conflictScalar.type.toString()).toBe(`Int!`) + expect(fields.conflictScalarReverse.type.toString()).toBe(`Nested!`) + expect(fields.conflictScalarArray.type.toString()).toBe(`[Int!]!`) + expect(fields.conflcitScalarArrayReverse.type.toString()).toBe( + `[Nested!]!` + ) + + // Explicit typeDefs have priority on nested types as well + expect(nestedFields.conflict.type.toString()).toBe(`String!`) + expect(nestedNestedFields.conflict.type.toString()).toBe(`String!`) + + // Date resolvers + expect(fields.explicitDate.resolve).toBeUndefined() + expect(fields.inferDate.resolve).toBeDefined() + }) + + it(`with @dontInfer directive (implicit "noDefaultResolvers: true")`, async () => { const schema = await buildTestSchema({ infer: false, }) @@ -253,12 +313,64 @@ describe(`merges explicit and inferred type definitions`, () => { expect(nestedFields.conflict.type.toString()).toBe(`String!`) expect(nestedNestedFields.conflict.type.toString()).toBe(`String!`) + // Date resolvers + expect(fields.explicitDate.resolve).toBeUndefined() + }) + + it(`with "infer(noDefaultResolvers: false)"`, async () => { + const schema = await buildTestSchema({ + infer: true, + addDefaultResolvers: true, + }) + const fields = schema.getType(`Test`).getFields() + const nestedFields = schema.getType(`Nested`).getFields() + const nestedNestedFields = schema.getType(`NestedNested`).getFields() + + // Non-conflicting top-level fields added + expect(fields.foo.type.toString()).toBe(`Boolean`) + expect(fields.bar.type.toString()).toBe(`Boolean!`) + + // Non-conflicting fields added on nested type + expect(fields.nested.type.toString()).toBe(`Nested!`) + expect(fields.nestedArray.type.toString()).toBe(`[Nested!]!`) + expect(nestedFields.foo.type.toString()).toBe(`Boolean`) + expect(nestedFields.bar.type.toString()).toBe(`Boolean!`) + expect(nestedNestedFields.foo.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.bar.type.toString()).toBe(`Boolean!`) + + // When type is referenced more than once on typeDefs, all non-conflicting + // fields are added + expect(nestedFields.extra.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.notExtra.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.extraExtra.type.toString()).toBe(`Boolean`) + expect(nestedNestedFields.extraExtraExtra.type.toString()).toBe( + `Boolean` + ) + + // Explicit typeDefs have proprity in case of type conflict + expect(fields.conflictType.type.toString()).toBe(`String!`) + expect(fields.conflictArray.type.toString()).toBe(`Int!`) + expect(fields.conflictArrayReverse.type.toString()).toBe(`[Int!]!`) + expect(fields.conflictArrayType.type.toString()).toBe(`[String!]!`) + expect(fields.conflictScalar.type.toString()).toBe(`Int!`) + expect(fields.conflictScalarReverse.type.toString()).toBe(`Nested!`) + expect(fields.conflictScalarArray.type.toString()).toBe(`[Int!]!`) + expect(fields.conflcitScalarArrayReverse.type.toString()).toBe( + `[Nested!]!` + ) + + // Explicit typeDefs have priority on nested types as well + expect(nestedFields.conflict.type.toString()).toBe(`String!`) + expect(nestedNestedFields.conflict.type.toString()).toBe(`String!`) + // Date resolvers expect(fields.explicitDate.resolve).toBeDefined() + expect(fields.inferDate.resolve).toBeDefined() }) - it(`with noDefaultResolvers: true`, async () => { + it(`with "infer(noDefaultResolvers: true)"`, async () => { const schema = await buildTestSchema({ + infer: true, addDefaultResolvers: false, }) const fields = schema.getType(`Test`).getFields() @@ -307,10 +419,62 @@ describe(`merges explicit and inferred type definitions`, () => { expect(fields.inferDate.resolve).toBeDefined() }) - it(`with both @dontInfer and noDefaultResolvers: true`, async () => { + it(`with "@dontInfer(noDefaultResolvers: false)"`, async () => { + const schema = await buildTestSchema({ + infer: false, + addDefaultResolvers: true, + }) + + const fields = schema.getType(`Test`).getFields() + const nestedFields = schema.getType(`Nested`).getFields() + const nestedNestedFields = schema.getType(`NestedNested`).getFields() + + // Non-conflicting top-level fields added + expect(fields.bar.type.toString()).toBe(`Boolean!`) + + // Not adding inferred fields + expect(fields.foo).toBeUndefined() + expect(nestedFields.foo).toBeUndefined() + expect(nestedNestedFields.foo).toBeUndefined() + expect(nestedFields.extra).toBeUndefined() + expect(nestedNestedFields.extraExtra).toBeUndefined() + expect(nestedNestedFields.extraExtraExtra).toBeUndefined() + expect(fields.inferDate).toBeUndefined() + + // Non-conflicting fields added on nested type + expect(fields.nested.type.toString()).toBe(`Nested!`) + expect(fields.nestedArray.type.toString()).toBe(`[Nested!]!`) + expect(nestedFields.bar.type.toString()).toBe(`Boolean!`) + expect(nestedNestedFields.bar.type.toString()).toBe(`Boolean!`) + + // When type is referenced more than once on typeDefs, all non-conflicting + // fields are added + expect(nestedNestedFields.notExtra.type.toString()).toBe(`Boolean`) + + // Explicit typeDefs have proprity in case of type conflict + expect(fields.conflictType.type.toString()).toBe(`String!`) + expect(fields.conflictArray.type.toString()).toBe(`Int!`) + expect(fields.conflictArrayReverse.type.toString()).toBe(`[Int!]!`) + expect(fields.conflictArrayType.type.toString()).toBe(`[String!]!`) + expect(fields.conflictScalar.type.toString()).toBe(`Int!`) + expect(fields.conflictScalarReverse.type.toString()).toBe(`Nested!`) + expect(fields.conflictScalarArray.type.toString()).toBe(`[Int!]!`) + expect(fields.conflcitScalarArrayReverse.type.toString()).toBe( + `[Nested!]!` + ) + + // Explicit typeDefs have priority on nested types as well + expect(nestedFields.conflict.type.toString()).toBe(`String!`) + expect(nestedNestedFields.conflict.type.toString()).toBe(`String!`) + + // Date resolvers + expect(fields.explicitDate.resolve).toBeDefined() + }) + + it(`with "@dontInfer(noDefaultResolvers: true)"`, async () => { const schema = await buildTestSchema({ - addDefaultResolvers: false, infer: false, + addDefaultResolvers: false, }) const fields = schema.getType(`Test`).getFields() @@ -361,6 +525,86 @@ describe(`merges explicit and inferred type definitions`, () => { }) }) + it(`adds explicit resolvers through directives`, async () => { + const typeDefs = ` + type Test implements Node @infer { + explicitDate: Date @dateformat + } + + type LinkTest implements Node @infer { + link: Test! @link + links: [Test!]! @link + } + ` + store.dispatch({ type: `CREATE_TYPES`, payload: typeDefs }) + await build({}) + const { schema } = store.getState() + + const { link, links } = schema.getType(`LinkTest`).getFields() + expect(link.type.toString()).toBe(`Test!`) + expect(links.type.toString()).toBe(`[Test!]!`) + expect(link.resolve).toBeDefined() + expect(links.resolve).toBeDefined() + + const { explicitDate, inferDate } = schema.getType(`Test`).getFields() + expect(explicitDate.resolve).toBeDefined() + expect(inferDate.resolve).toBeDefined() + }) + + it(`adds explicit resolvers through extensions`, async () => { + const typeDefs = [ + buildObjectType({ + name: `Test`, + interfaces: [`Node`], + extensions: { + infer: true, + }, + fields: { + explicitDate: { + type: `Date`, + extensions: { + dateformat: {}, + }, + }, + }, + }), + buildObjectType({ + name: `LinkTest`, + interfaces: [`Node`], + extensions: { + infer: true, + }, + fields: { + link: { + type: `Test!`, + extensions: { + link: {}, + }, + }, + links: { + type: `[Test!]!`, + extensions: { + link: true, + }, + }, + }, + }), + ] + store.dispatch({ type: `CREATE_TYPES`, payload: typeDefs }) + await build({}) + const { schema } = store.getState() + + const { link, links } = schema.getType(`LinkTest`).getFields() + expect(link.type.toString()).toBe(`Test!`) + expect(links.type.toString()).toBe(`[Test!]!`) + expect(link.resolve).toBeDefined() + expect(links.resolve).toBeDefined() + + const { explicitDate, inferDate } = schema.getType(`Test`).getFields() + expect(explicitDate.resolve).toBeDefined() + expect(inferDate.resolve).toBeDefined() + }) + it(`honors array depth when merging types`, async () => { const typeDefs = ` type FooBar { diff --git a/packages/gatsby/src/schema/infer/add-inferred-fields.js b/packages/gatsby/src/schema/infer/add-inferred-fields.js index b27c53aacbb77..3e8b8f7374e2f 100644 --- a/packages/gatsby/src/schema/infer/add-inferred-fields.js +++ b/packages/gatsby/src/schema/infer/add-inferred-fields.js @@ -1,17 +1,11 @@ const _ = require(`lodash`) -const { - defaultFieldResolver, - getNamedType, - GraphQLObjectType, - GraphQLList, -} = require(`graphql`) const { ObjectTypeComposer } = require(`graphql-compose`) +const { GraphQLList } = require(`graphql`) const invariant = require(`invariant`) const report = require(`gatsby-cli/lib/reporter`) const { isFile } = require(`./is-file`) -const { link, fileByPath } = require(`../resolvers`) -const { isDate, dateResolver } = require(`../types/date`) +const { isDate } = require(`../types/date`) const is32BitInteger = require(`./is-32-bit-integer`) const addInferredFields = ({ @@ -19,10 +13,18 @@ const addInferredFields = ({ typeComposer, exampleValue, nodeStore, - inferConfig, typeMapping, parentSpan, }) => { + const config = getInferenceConfig({ + typeComposer, + defaults: { + shouldAddFields: true, + shouldAddDefaultResolvers: typeComposer.hasExtension(`infer`) + ? false + : true, + }, + }) addInferredFieldsImpl({ schemaComposer, typeComposer, @@ -30,8 +32,7 @@ const addInferredFields = ({ exampleObject: exampleValue, prefix: typeComposer.getTypeName(), typeMapping, - addNewFields: inferConfig ? inferConfig.infer : true, - addDefaultResolvers: inferConfig ? inferConfig.addDefaultResolvers : true, + config, }) } @@ -46,32 +47,23 @@ const addInferredFieldsImpl = ({ exampleObject, typeMapping, prefix, - addNewFields, - addDefaultResolvers, + config, }) => { const fields = [] Object.keys(exampleObject).forEach(unsanitizedKey => { - const exampleValue = exampleObject[unsanitizedKey] - fields.push( - getFieldConfig({ - schemaComposer, - typeComposer, - nodeStore, - prefix, - exampleValue, - unsanitizedKey, - typeMapping, - addNewFields, - addDefaultResolvers, - }) - ) + const key = createFieldName(unsanitizedKey) + fields.push({ + key, + unsanitizedKey, + exampleValue: exampleObject[unsanitizedKey], + }) }) const fieldsByKey = _.groupBy(fields, field => field.key) Object.keys(fieldsByKey).forEach(key => { const possibleFields = fieldsByKey[key] - let fieldConfig + let selectedField if (possibleFields.length > 1) { const field = resolveMultipleFields(possibleFields) const possibleFieldsNames = possibleFields @@ -84,65 +76,62 @@ const addInferredFieldsImpl = ({ field.unsanitizedKey }\`.` ) - fieldConfig = field.fieldConfig + selectedField = field } else { - fieldConfig = possibleFields[0].fieldConfig + selectedField = possibleFields[0] } - let arrays = 0 - let namedInferredType = fieldConfig.type - while (Array.isArray(namedInferredType)) { - namedInferredType = namedInferredType[0] - arrays++ - } + const fieldConfig = getFieldConfig({ + ...selectedField, + schemaComposer, + typeComposer, + nodeStore, + prefix, + typeMapping, + config, + }) - if (typeComposer.hasField(key)) { - const fieldType = typeComposer.getFieldType(key) + if (!fieldConfig) return - let lists = 0 - let namedFieldType = fieldType - while (namedFieldType.ofType) { - if (namedFieldType instanceof GraphQLList) { - lists++ - } - namedFieldType = namedFieldType.ofType + if (!typeComposer.hasField(key)) { + if (config.shouldAddFields) { + typeComposer.addFields({ [key]: fieldConfig }) + typeComposer.setFieldExtension(key, `createdFrom`, `inference`) } - - const namedInferredTypeName = - typeof namedInferredType === `string` - ? namedInferredType - : namedInferredType.getTypeName() - - if (arrays === lists && namedFieldType.name === namedInferredTypeName) { + } else { + // Deprecated, remove in v3 + if (config.shouldAddDefaultResolvers) { + // Add default resolvers to existing fields if the type matches + // and the field has neither args nor resolver explicitly defined. + const field = typeComposer.getField(key) if ( - namedFieldType instanceof GraphQLObjectType && - namedInferredType instanceof ObjectTypeComposer + field.type.toString().replace(/[[\]!]/g, ``) === + fieldConfig.type.toString() && + _.isEmpty(field.args) && + !field.resolve ) { - const fieldTypeComposer = typeComposer.getFieldTC(key) - const inferredFields = namedInferredType.getFields() - fieldTypeComposer.addFields(inferredFields) - } - if (addDefaultResolvers) { - let field = typeComposer.getField(key) - if (!field.type) { - field = { - type: field, - } + const { extensions } = fieldConfig + if (extensions) { + Object.keys(extensions) + .filter(name => + // It is okay to list allowed extensions explicitly here, + // since this is deprecated anyway and won't change. + [`dateformat`, `fileByRelativePath`, `link`].includes(name) + ) + .forEach(name => { + if (!typeComposer.hasFieldExtension(key, name)) { + typeComposer.setFieldExtension(key, name, extensions[name]) + report.warn( + `Deprecation warning - adding inferred resolver for field ` + + `${typeComposer.getTypeName()}.${key}. In Gatsby v3, ` + + `only fields with an explicit directive/extension will ` + + `get a resolver.` + ) + } + }) } - if (_.isEmpty(field.args) && fieldConfig.args) { - field.args = fieldConfig.args - } - if (!field.resolve && fieldConfig.resolve) { - field.resolve = fieldConfig.resolve - } - typeComposer.setField(key, field) } } - } else if (addNewFields) { - if (namedInferredType instanceof ObjectTypeComposer) { - schemaComposer.add(namedInferredType) - } - typeComposer.setField(key, fieldConfig) } }) @@ -155,12 +144,11 @@ const getFieldConfig = ({ nodeStore, prefix, exampleValue, + key, unsanitizedKey, typeMapping, - addNewFields, - addDefaultResolvers, + config, }) => { - let key = createFieldName(unsanitizedKey) const selector = `${prefix}.${key}` let arrays = 0 @@ -175,14 +163,13 @@ const getFieldConfig = ({ // TODO: Use `prefix` instead of `selector` in hasMapping and getFromMapping? // i.e. does the config contain sanitized field names? fieldConfig = getFieldConfigFromMapping({ typeMapping, selector }) - } else if (key.includes(`___NODE`)) { + } else if (unsanitizedKey.includes(`___NODE`)) { fieldConfig = getFieldConfigFromFieldNameConvention({ schemaComposer, nodeStore, value: exampleValue, key: unsanitizedKey, }) - key = key.split(`___NODE`)[0] } else { fieldConfig = getSimpleFieldConfig({ schemaComposer, @@ -192,21 +179,21 @@ const getFieldConfig = ({ value, selector, typeMapping, - addNewFields, - addDefaultResolvers, + config, + arrays, }) } + if (!fieldConfig) return null + // Proxy resolver to unsanitized fieldName in case it contained invalid characters - if (key !== unsanitizedKey) { - const resolver = fieldConfig.resolve || defaultFieldResolver + if (key !== unsanitizedKey.split(`___NODE`)[0]) { fieldConfig = { ...fieldConfig, - resolve: (source, args, context, info) => - resolver(source, args, context, { - ...info, - fieldName: unsanitizedKey, - }), + extensions: { + ...(fieldConfig.extensions || {}), + proxyFrom: unsanitizedKey, + }, } } @@ -215,11 +202,7 @@ const getFieldConfig = ({ arrays-- } - return { - key, - unsanitizedKey, - fieldConfig, - } + return fieldConfig } const resolveMultipleFields = possibleFields => { @@ -249,7 +232,12 @@ const hasMapping = (mapping, selector) => const getFieldConfigFromMapping = ({ typeMapping, selector }) => { const [type, ...path] = typeMapping[selector].split(`.`) - return { type, resolve: link({ by: path.join(`.`) || `id` }) } + return { + type, + extensions: { + link: { by: path.join(`.`) || `id` }, + }, + } } // probably should be in example value @@ -288,23 +276,22 @@ const getFieldConfigFromFieldNameConvention = ({ // scalar fields link to different types. Similarly, an array of objects // with foreign-key fields will produce union types if those foreign-key // fields are arrays, but not if they are scalars. See the tests for an example. - // FIXME: The naming of union types is a breaking change. In current master, - // the type name includes the key, which is (i) potentially not unique, and - // (ii) hinders reusing types. if (linkedTypes.length > 1) { const typeName = linkedTypes.sort().join(``) + `Union` type = schemaComposer.getOrCreateUTC(typeName, utc => { - const types = linkedTypes.map(typeName => - schemaComposer.getOrCreateOTC(typeName) - ) - utc.setTypes(types) + utc.setTypes(linkedTypes.map(typeName => schemaComposer.getOTC(typeName))) utc.setResolveType(node => node.internal.type) }) } else { type = linkedTypes[0] } - return { type, resolve: link({ by: foreignKey || `id` }) } + return { + type, + extensions: { + link: { by: foreignKey || `id`, from: key }, + }, + } } const getSimpleFieldConfig = ({ @@ -315,8 +302,8 @@ const getSimpleFieldConfig = ({ value, selector, typeMapping, - addNewFields, - addDefaultResolvers, + config, + arrays, }) => { switch (typeof value) { case `boolean`: @@ -325,48 +312,64 @@ const getSimpleFieldConfig = ({ return { type: is32BitInteger(value) ? `Int` : `Float` } case `string`: if (isDate(value)) { - return dateResolver + return { type: `Date`, extensions: { dateformat: {} } } } - // FIXME: The weird thing is that we are trying to infer a File, - // but cannot assume that a source plugin for File nodes is actually present. - if (schemaComposer.has(`File`) && isFile(nodeStore, selector, value)) { + if (isFile(nodeStore, selector, value)) { // NOTE: For arrays of files, where not every path references // a File node in the db, it is semi-random if the field is // inferred as File or String, since the exampleValue only has // the first entry (which could point to an existing file or not). - return { type: `File`, resolve: fileByPath } + return { type: `File`, extensions: { fileByRelativePath: {} } } } return { type: `String` } case `object`: if (value instanceof Date) { - return dateResolver + return { type: `Date`, extensions: { dateformat: {} } } } if (value instanceof String) { return { type: `String` } } if (value /* && depth < MAX_DEPTH*/) { - // We only create a temporary TypeComposer on nested fields - // (either a clone of an existing field type, or a temporary new one), - // because we don't yet know if this type should end up in the schema. - // It might be for a possibleField that will be disregarded later, - // so we cannot mutate the original. let fieldTypeComposer - if ( - typeComposer.hasField(key) && - getNamedType(typeComposer.getFieldType(key)) instanceof - GraphQLObjectType - ) { - const originalFieldTypeComposer = typeComposer.getFieldTC(key) - fieldTypeComposer = originalFieldTypeComposer.clone( - originalFieldTypeComposer.getTypeName() - ) + if (typeComposer.hasField(key)) { + fieldTypeComposer = typeComposer.getFieldTC(key) + // If we have an object as a field value, but the field type is + // explicitly defined as something other than an ObjectType + // we can bail early. + if (!(fieldTypeComposer instanceof ObjectTypeComposer)) return null + // If the array depth of the field value and of the explicitly + // defined field type don't match we can also bail early. + let lists = 0 + let fieldType = typeComposer.getFieldType(key) + while (fieldType.ofType) { + if (fieldType instanceof GraphQLList) lists++ + fieldType = fieldType.ofType + } + if (lists !== arrays) return null } else { - fieldTypeComposer = ObjectTypeComposer.createTemp( + // When the field type has not been explicitly defined, we + // don't need to continue in case of @dontInfer, because + // "addDefaultResolvers: true" only makes sense for + // pre-existing types. + if (!config.shouldAddFields) return null + fieldTypeComposer = ObjectTypeComposer.create( createTypeName(selector), schemaComposer ) + fieldTypeComposer.setExtension(`createdFrom`, `inference`) + fieldTypeComposer.setExtension( + `plugin`, + typeComposer.getExtension(`plugin`) + ) } + // Inference config options are either explicitly defined on a type + // with directive/extension, or inherited from the parent type. + const inferenceConfig = getInferenceConfig({ + typeComposer: fieldTypeComposer, + defaults: config, + }) + return { type: addInferredFieldsImpl({ schemaComposer, @@ -375,8 +378,7 @@ const getSimpleFieldConfig = ({ exampleObject: value, typeMapping, prefix: selector, - addNewFields, - addDefaultResolvers, + config: inferenceConfig, }), } } @@ -407,7 +409,8 @@ const createFieldName = key => { `GraphQL field name (key) is not a string: \`${key}\`.` ) - const replaced = key.replace(NON_ALPHA_NUMERIC_EXPR, `_`) + const fieldName = key.split(`___NODE`)[0] + const replaced = fieldName.replace(NON_ALPHA_NUMERIC_EXPR, `_`) // key is invalid; normalize with leading underscore and rest with x if (replaced.match(/^__/)) { @@ -421,3 +424,14 @@ const createFieldName = key => { return replaced } + +const getInferenceConfig = ({ typeComposer, defaults }) => { + return { + shouldAddFields: typeComposer.hasExtension(`infer`) + ? typeComposer.getExtension(`infer`) + : defaults.shouldAddFields, + shouldAddDefaultResolvers: typeComposer.hasExtension(`addDefaultResolvers`) + ? typeComposer.getExtension(`addDefaultResolvers`) + : defaults.shouldAddDefaultResolvers, + } +} diff --git a/packages/gatsby/src/schema/infer/get-infer-config.js b/packages/gatsby/src/schema/infer/get-infer-config.js deleted file mode 100644 index 1591be4461044..0000000000000 --- a/packages/gatsby/src/schema/infer/get-infer-config.js +++ /dev/null @@ -1,27 +0,0 @@ -// @flow - -export interface InferConfig { - infer: boolean; - addDefaultResolvers: boolean; -} - -const DEFAULT_INFER_CONFIG: InferConfig = { - infer: true, - addDefaultResolvers: true, -} - -// Get inferance config from type directives -const getInferConfig: ( - typeComposer: TypeComposer -) => InferConfig = typeComposer => { - return { - infer: typeComposer.hasExtension(`infer`) - ? typeComposer.getExtension(`infer`) - : DEFAULT_INFER_CONFIG.infer, - addDefaultResolvers: typeComposer.hasExtension(`addDefaultResolvers`) - ? typeComposer.getExtension(`addDefaultResolvers`) - : DEFAULT_INFER_CONFIG.addDefaultResolvers, - } -} - -module.exports = getInferConfig diff --git a/packages/gatsby/src/schema/infer/index.js b/packages/gatsby/src/schema/infer/index.js index 85c5249b77e4d..49c2a720dd626 100644 --- a/packages/gatsby/src/schema/infer/index.js +++ b/packages/gatsby/src/schema/infer/index.js @@ -1,49 +1,11 @@ const report = require(`gatsby-cli/lib/reporter`) +const { ObjectTypeComposer } = require(`graphql-compose`) const { getExampleValue } = require(`./example-value`) const { addNodeInterface, getNodeInterface, } = require(`../types/node-interface`) const { addInferredFields } = require(`./add-inferred-fields`) -const getInferConfig = require(`./get-infer-config`) - -const addInferredType = ({ - schemaComposer, - typeComposer, - nodeStore, - typeConflictReporter, - typeMapping, - parentSpan, -}) => { - const typeName = typeComposer.getTypeName() - const nodes = nodeStore.getNodesByType(typeName) - if ( - !typeComposer.hasExtension(`plugin`) && - typeComposer.getExtension(`createdFrom`) === `infer` - ) { - typeComposer.setExtension(`plugin`, nodes[0].internal.owner) - } - const exampleValue = getExampleValue({ - nodes, - typeName, - typeConflictReporter, - ignoreFields: [ - ...getNodeInterface({ schemaComposer }).getFieldNames(), - `$loki`, - ], - }) - - addInferredFields({ - schemaComposer, - typeComposer, - nodeStore, - exampleValue, - inferConfig: getInferConfig(typeComposer), - typeMapping, - parentSpan, - }) - return typeComposer -} const addInferredTypes = ({ schemaComposer, @@ -57,53 +19,95 @@ const addInferredTypes = ({ const typeNames = putFileFirst(nodeStore.getTypes()) const noNodeInterfaceTypes = [] + const typesToInfer = [] + typeNames.forEach(typeName => { let typeComposer - let inferConfig if (schemaComposer.has(typeName)) { typeComposer = schemaComposer.getOTC(typeName) - inferConfig = getInferConfig(typeComposer) - if (inferConfig.infer) { + // Infer if we have enabled "@infer" or if it's "@dontInfer" but we + // have "addDefaultResolvers: true" + const runInfer = typeComposer.hasExtension(`infer`) + ? typeComposer.getExtension(`infer`) || + typeComposer.getExtension(`addDefaultResolvers`) + : true + if (runInfer) { if (!typeComposer.hasInterface(`Node`)) { - noNodeInterfaceTypes.push(typeComposer.getType()) + noNodeInterfaceTypes.push(typeName) } + typesToInfer.push(typeComposer) } } else { - typeComposer = schemaComposer.createObjectTC(typeName) - typeComposer.setExtension(`createdFrom`, `infer`) + typeComposer = ObjectTypeComposer.create(typeName, schemaComposer) addNodeInterface({ schemaComposer, typeComposer }) + typeComposer.setExtension(`createdFrom`, `inference`) + typesToInfer.push(typeComposer) } }) - // XXX(freiksenet): We iterate twice to pre-create all types - const typeComposers = typeNames.map(typeName => - addInferredType({ - schemaComposer, - nodeStore, - typeConflictReporter, - typeComposer: schemaComposer.getOTC(typeName), - typeMapping, - parentSpan, - }) - ) - if (noNodeInterfaceTypes.length > 0) { - noNodeInterfaceTypes.forEach(type => { + noNodeInterfaceTypes.forEach(typeName => { report.warn( - `Type \`${type}\` declared in \`createTypes\` looks like a node, ` + + `Type \`${typeName}\` declared in \`createTypes\` looks like a node, ` + `but doesn't implement a \`Node\` interface. It's likely that you should ` + `add the \`Node\` interface to your type def:\n\n` + - `\`type ${type} implements Node { ... }\`\n\n` + + `\`type ${typeName} implements Node { ... }\`\n\n` + `If you know that you don't want it to be a node (which would mean no ` + `root queries to retrieve it), you can explicitly disable inference ` + `for it:\n\n` + - `\`type ${type} @dontInfer { ... }\`` + `\`type ${typeName} @dontInfer { ... }\`` ) }) report.panic(`Building schema failed`) } - return typeComposers + return typesToInfer.map(typeComposer => + addInferredType({ + schemaComposer, + typeComposer, + nodeStore, + typeConflictReporter, + typeMapping, + parentSpan, + }) + ) +} + +const addInferredType = ({ + schemaComposer, + typeComposer, + nodeStore, + typeConflictReporter, + typeMapping, + parentSpan, +}) => { + const typeName = typeComposer.getTypeName() + const nodes = nodeStore.getNodesByType(typeName) + // TODO: Move this to where the type is created once we can get + // node type owner information directly from store + if (typeComposer.getExtension(`createdFrom`) === `inference`) { + typeComposer.setExtension(`plugin`, nodes[0].internal.owner) + } + + const exampleValue = getExampleValue({ + nodes, + typeName, + typeConflictReporter, + ignoreFields: [ + ...getNodeInterface({ schemaComposer }).getFieldNames(), + `$loki`, + ], + }) + + addInferredFields({ + schemaComposer, + typeComposer, + nodeStore, + exampleValue, + typeMapping, + parentSpan, + }) + return typeComposer } const putFileFirst = typeNames => { diff --git a/packages/gatsby/src/schema/infer/type-conflict-reporter.js b/packages/gatsby/src/schema/infer/type-conflict-reporter.js index 9db57638f40ff..2d484b3e06969 100644 --- a/packages/gatsby/src/schema/infer/type-conflict-reporter.js +++ b/packages/gatsby/src/schema/infer/type-conflict-reporter.js @@ -134,7 +134,14 @@ class TypeConflictReporter { printConflicts() { if (this.entries.size > 0) { report.warn( - `There are conflicting field types in your data. GraphQL schema will omit those fields.` + `There are conflicting field types in your data.\n\n` + + `If you have explicitly defined a type for those fields, you can ` + + `safely ignore this warning message.\n` + + `Otherwise, Gatsby will omit those fields from the GraphQL schema.\n\n` + + `If you know all field types in advance, the best strategy is to ` + + `explicitly define them with the \`createTypes\` action, and skip ` + + `inference with the \`@dontInfer\` directive.\n` + + `See https://www.gatsbyjs.org/docs/actions/#createTypes` ) this.entries.forEach(entry => entry.printEntry()) } diff --git a/packages/gatsby/src/schema/resolvers.js b/packages/gatsby/src/schema/resolvers.js index 6166b4ead31eb..75a3c030daf4c 100644 --- a/packages/gatsby/src/schema/resolvers.js +++ b/packages/gatsby/src/schema/resolvers.js @@ -105,7 +105,7 @@ const paginate = (results = [], { skip = 0, limit }) => { } } -const link = ({ by, from }) => async (source, args, context, info) => { +const link = ({ by = `id`, from }) => async (source, args, context, info) => { const fieldValue = source && source[from || info.fieldName] if (fieldValue == null || _.isPlainObject(fieldValue)) return fieldValue @@ -152,8 +152,8 @@ const link = ({ by, from }) => async (source, args, context, info) => { ) } -const fileByPath = (source, args, context, info) => { - const fieldValue = source && source[info.fieldName] +const fileByPath = ({ from }) => (source, args, context, info) => { + const fieldValue = source && source[from || info.fieldName] if (fieldValue == null || _.isPlainObject(fieldValue)) return fieldValue if ( diff --git a/packages/gatsby/src/schema/schema-composer.js b/packages/gatsby/src/schema/schema-composer.js index 8b12a0f1d6a52..8d62f3ebade26 100644 --- a/packages/gatsby/src/schema/schema-composer.js +++ b/packages/gatsby/src/schema/schema-composer.js @@ -1,14 +1,14 @@ -const { SchemaComposer } = require(`graphql-compose`) +const { SchemaComposer, GraphQLJSON } = require(`graphql-compose`) const { getNodeInterface } = require(`./types/node-interface`) const { GraphQLDate } = require(`./types/date`) -const { InferDirective, DontInferDirective } = require(`./types/directives`) +const { addDirectives } = require(`./extensions`) const createSchemaComposer = () => { const schemaComposer = new SchemaComposer() getNodeInterface({ schemaComposer }) schemaComposer.addAsComposer(GraphQLDate) - schemaComposer.addDirective(InferDirective) - schemaComposer.addDirective(DontInferDirective) + schemaComposer.addAsComposer(GraphQLJSON) + addDirectives({ schemaComposer }) return schemaComposer } diff --git a/packages/gatsby/src/schema/schema.js b/packages/gatsby/src/schema/schema.js index a482a422d0908..e95c4406f7618 100644 --- a/packages/gatsby/src/schema/schema.js +++ b/packages/gatsby/src/schema/schema.js @@ -5,8 +5,6 @@ const { isIntrospectionType, defaultFieldResolver, assertValidName, - getNamedType, - Kind, } = require(`graphql`) const { ObjectTypeComposer, @@ -14,11 +12,13 @@ const { UnionTypeComposer, InputTypeComposer, } = require(`graphql-compose`) + const apiRunner = require(`../utils/api-runner-node`) const report = require(`gatsby-cli/lib/reporter`) const { addNodeInterfaceFields } = require(`./types/node-interface`) const { addInferredType, addInferredTypes } = require(`./infer`) const { findOne, findManyPaginated } = require(`./resolvers`) +const { processFieldExtensions } = require(`./extensions`) const { getPagination } = require(`./types/pagination`) const { getSortInput } = require(`./types/sort`) const { getFilterInput } = require(`./types/filter`) @@ -119,19 +119,19 @@ const processTypeComposer = async ({ nodeStore, parentSpan, }) => { - if ( - typeComposer instanceof ObjectTypeComposer && - typeComposer.hasInterface(`Node`) - ) { - await addNodeInterfaceFields({ schemaComposer, typeComposer, parentSpan }) - await addResolvers({ schemaComposer, typeComposer, parentSpan }) - await addConvenienceChildrenFields({ - schemaComposer, - typeComposer, - nodeStore, - parentSpan, - }) - await addTypeToRootQuery({ schemaComposer, typeComposer, parentSpan }) + if (typeComposer instanceof ObjectTypeComposer) { + await processFieldExtensions({ schemaComposer, typeComposer, parentSpan }) + if (typeComposer.hasInterface(`Node`)) { + await addNodeInterfaceFields({ schemaComposer, typeComposer, parentSpan }) + await addResolvers({ schemaComposer, typeComposer, parentSpan }) + await addConvenienceChildrenFields({ + schemaComposer, + typeComposer, + nodeStore, + parentSpan, + }) + await addTypeToRootQuery({ schemaComposer, typeComposer, parentSpan }) + } } } @@ -205,39 +205,54 @@ const processAddedType = ({ typeComposer.setExtension(`plugin`, plugin ? plugin.name : null) if (createdFrom === `sdl`) { - if (type.astNode && type.astNode.directives) { - type.astNode.directives.forEach(directive => { - if (directive.name.value === `infer`) { - typeComposer.setExtension(`infer`, true) - typeComposer.setExtension( - `addDefaultResolvers`, - getNoDefaultResolvers(directive) - ) - } else if (directive.name.value === `dontInfer`) { - typeComposer.setExtension(`infer`, false) - typeComposer.setExtension( - `addDefaultResolvers`, - getNoDefaultResolvers(directive) - ) - } - }) - } + const directives = typeComposer.getDirectives() + directives.forEach(({ name, args }) => { + switch (name) { + case `infer`: + case `dontInfer`: + typeComposer.setExtension(`infer`, name === `infer`) + if (args.noDefaultResolvers != null) { + typeComposer.setExtension( + `addDefaultResolvers`, + !args.noDefaultResolvers + ) + } + break + default: + } + }) } - return typeComposer -} + if ( + typeComposer instanceof ObjectTypeComposer || + typeComposer instanceof InterfaceTypeComposer + ) { + typeComposer.getFieldNames().forEach(fieldName => { + typeComposer.setFieldExtension(fieldName, `createdFrom`, createdFrom) + typeComposer.setFieldExtension( + fieldName, + `plugin`, + plugin ? plugin.name : null + ) -const getNoDefaultResolvers = directive => { - const noDefaultResolvers = directive.arguments.find( - ({ name }) => name.value === `noDefaultResolvers` - ) - if (noDefaultResolvers) { - if (noDefaultResolvers.value.kind === Kind.BOOLEAN) { - return !noDefaultResolvers.value.value - } + if (createdFrom === `sdl`) { + const directives = typeComposer.getFieldDirectives(fieldName) + directives.forEach(({ name, args }) => { + typeComposer.setFieldExtension(fieldName, name, args) + }) + } + }) } - return null + if (typeComposer.hasExtension(`addDefaultResolvers`)) { + report.warn( + `Deprecation warning - "noDefaultResolvers" is deprecated. In Gatsby 3, ` + + `defined fields won't get resolvers, unless explicitly added with a ` + + `directive/extension.` + ) + } + + return typeComposer } const checkIsAllowedTypeName = name => { @@ -350,14 +365,9 @@ const addThirdPartySchemas = ({ }) => { thirdPartySchemas.forEach(schema => { const schemaQueryType = schema.getQueryType() - const queryTC = ObjectTypeComposer.createTemp(schemaQueryType) - processThirdPartyType({ - schemaComposer, - typeComposer: queryTC, - schemaQueryType, - }) - const fields = queryTC.getFields() - schemaComposer.Query.addFields(fields) + const queryTC = schemaComposer.createTempTC(schemaQueryType) + processThirdPartyTypeFields({ typeComposer: queryTC, schemaQueryType }) + schemaComposer.Query.addFields(queryTC.getFields()) // Explicitly add the third-party schema's types, so they can be targeted // in `createResolvers` API. @@ -367,38 +377,35 @@ const addThirdPartySchemas = ({ if ( type !== schemaQueryType && !isSpecifiedScalarType(type) && - !isIntrospectionType(type) + !isIntrospectionType(type) && + type.name !== `Date` && + type.name !== `JSON` ) { - schemaComposer.addAsComposer(type) - const typeComposer = schemaComposer.getAnyTC(type.name) - processThirdPartyType({ schemaComposer, typeComposer, schemaQueryType }) + const typeComposer = schemaComposer.createTC(type) + if ( + typeComposer instanceof ObjectTypeComposer || + typeComposer instanceof InterfaceTypeComposer + ) { + processThirdPartyTypeFields({ typeComposer, schemaQueryType }) + } + typeComposer.setExtension(`createdFrom`, `thirdPartySchema`) schemaComposer.addSchemaMustHaveType(typeComposer) } }) }) } -const processThirdPartyType = ({ - schemaComposer, - typeComposer, - schemaQueryType, -}) => { - typeComposer.getType().isThirdPartyType = true +const processThirdPartyTypeFields = ({ typeComposer, schemaQueryType }) => { // Fix for types that refer to Query. Thanks Relay Classic! - if ( - typeComposer instanceof ObjectTypeComposer || - typeComposer instanceof InterfaceTypeComposer - ) { - typeComposer.getFieldNames().forEach(fieldName => { - const fieldType = typeComposer.getFieldType(fieldName) - if (getNamedType(fieldType) === schemaQueryType) { - typeComposer.extendField(fieldName, { - type: fieldType.toString().replace(schemaQueryType.name, `Query`), - }) - } - }) - } - return typeComposer + typeComposer.getFieldNames().forEach(fieldName => { + const field = typeComposer.getField(fieldName) + const fieldType = field.type.toString() + if (fieldType.replace(/[[\]!]/g, ``) === schemaQueryType.name) { + typeComposer.extendField(fieldName, { + type: fieldType.replace(schemaQueryType.name, `Query`), + }) + } + }) } const addCustomResolveFunctions = async ({ schemaComposer, parentSpan }) => { @@ -420,7 +427,7 @@ const addCustomResolveFunctions = async ({ schemaComposer, parentSpan }) => { !fieldTypeName || fieldTypeName.replace(/!/g, ``) === originalTypeName.replace(/!/g, ``) || - tc.getType().isThirdPartyType + tc.getExtension(`createdFrom`) === `thirdPartySchema` ) { const newConfig = {} if (fieldConfig.type) { @@ -502,8 +509,6 @@ const addResolvers = ({ schemaComposer, typeComposer }) => { sort: sortInputTC, skip: `Int`, limit: `Int`, - // page: `Int`, - // perPage: { type: `Int`, defaultValue: 20 }, }, resolve: findManyPaginated(typeName), }) diff --git a/packages/gatsby/src/schema/types/__tests__/date.js b/packages/gatsby/src/schema/types/__tests__/date.js index 936e8056dc2e7..7bbf40ad6f47a 100644 --- a/packages/gatsby/src/schema/types/__tests__/date.js +++ b/packages/gatsby/src/schema/types/__tests__/date.js @@ -335,6 +335,7 @@ const nodes = [ invalidDate14: ``, invalidDate15: ` `, invalidDate16: `2012-04-01T00:basketball`, + defaultFormatDate: `2010-01-30T23:59:59.999-07:00`, }, ] @@ -346,33 +347,7 @@ describe(`dateResolver`, () => { ) }) - const buildTestSchema = async ({ - infer = true, - addDefaultResolvers = true, - }) => { - const inferDirective = infer ? `@infer` : `@dontInfer` - const noDefaultResolvers = addDefaultResolvers ? `false` : `true` - const typeDefs = [ - ` - type Test implements Node ${inferDirective}(noDefaultResolvers: ${noDefaultResolvers}) { - testDate: Date - explicitValidDate: Date - invalidHighPrecision: Date - invalidDate8: Date - invalidDate9: Date - invalidDate10: Date - invalidDate11: Date - invalidDate12: Date - invalidDate13: Date - invalidDate14: Date - invalidDate15: Date - invalidDate16: Date - }`, - ] - typeDefs.forEach(def => - store.dispatch({ type: `CREATE_TYPES`, payload: def }) - ) - + const buildTestSchema = async () => { await build({}) return store.getState().schema } @@ -423,12 +398,12 @@ describe(`dateResolver`, () => { expect(fields.invalidDate5.resolve).toBeUndefined() expect(fields.invalidDate6.resolve).toBeUndefined() expect(fields.invalidDate7.resolve).toBeUndefined() - expect(fields.invalidDate8.resolve).toBeUndefined() + expect(fields.invalidDate8).toBeUndefined() expect(fields.invalidDate9.resolve).toBeUndefined() - expect(fields.invalidDate10.resolve).toBeUndefined() + expect(fields.invalidDate10).toBeUndefined() expect(fields.invalidDate11.resolve).toBeUndefined() - expect(fields.invalidDate12.resolve).toBeUndefined() - expect(fields.invalidDate13.resolve).toBeUndefined() + expect(fields.invalidDate12).toBeUndefined() + expect(fields.invalidDate13).toBeUndefined() expect(fields.invalidDate14.resolve).toBeUndefined() expect(fields.invalidDate15.resolve).toBeUndefined() expect(fields.invalidDate16.resolve).toBeUndefined() diff --git a/packages/gatsby/src/schema/types/date.js b/packages/gatsby/src/schema/types/date.js index e17ec8a9be4c2..4c34d8838de3c 100644 --- a/packages/gatsby/src/schema/types/date.js +++ b/packages/gatsby/src/schema/types/date.js @@ -215,44 +215,48 @@ const formatDate = ({ return normalizedDate } -const dateResolver = { - type: `Date`, - args: { - formatString: { - type: GraphQLString, - description: oneLine` +const getDateResolver = defaults => { + const { locale, formatString } = defaults + return { + args: { + formatString: { + type: GraphQLString, + description: oneLine` Format the date using Moment.js' date tokens, e.g. \`date(formatString: "YYYY MMMM DD")\`. See https://momentjs.com/docs/#/displaying/format/ for documentation for different tokens.`, - }, - fromNow: { - type: GraphQLBoolean, - description: oneLine` + defaultValue: formatString, + }, + fromNow: { + type: GraphQLBoolean, + description: oneLine` Returns a string generated with Moment.js' \`fromNow\` function`, - }, - difference: { - type: GraphQLString, - description: oneLine` + }, + difference: { + type: GraphQLString, + description: oneLine` Returns the difference between this date and the current time. - Defaults to "miliseconds" but you can also pass in as the + Defaults to "milliseconds" but you can also pass in as the measurement "years", "months", "weeks", "days", "hours", "minutes", and "seconds".`, - }, - locale: { - type: GraphQLString, - description: oneLine` + }, + locale: { + type: GraphQLString, + description: oneLine` Configures the locale Moment.js will use to format the date.`, + defaultValue: locale, + }, }, - }, - resolve(source, args, context, { fieldName }) { - const date = source[fieldName] - if (date == null) return null + resolve(source, args, context, { fieldName }) { + const date = source[fieldName] + if (date == null) return null - return Array.isArray(date) - ? date.map(d => formatDate({ date: d, ...args })) - : formatDate({ date, ...args }) - }, + return Array.isArray(date) + ? date.map(d => formatDate({ date: d, ...args })) + : formatDate({ date, ...args }) + }, + } } -module.exports = { GraphQLDate, dateResolver, isDate, looksLikeADate } +module.exports = { GraphQLDate, getDateResolver, isDate, looksLikeADate } diff --git a/packages/gatsby/src/schema/types/directives.js b/packages/gatsby/src/schema/types/directives.js deleted file mode 100644 index 55894a2996677..0000000000000 --- a/packages/gatsby/src/schema/types/directives.js +++ /dev/null @@ -1,39 +0,0 @@ -const { - GraphQLBoolean, - GraphQLNonNull, - GraphQLDirective, - DirectiveLocation, -} = require(`graphql`) - -const InferDirective = new GraphQLDirective({ - name: `infer`, - description: `Infer fields for this type from nodes.`, - locations: [DirectiveLocation.OBJECT], - args: { - noDefaultResolvers: { - type: new GraphQLNonNull(GraphQLBoolean), - default: false, - description: `Don't add default resolvers to defined fields.`, - deprecationReason: `noDefaultResolvers is deprecated, annotate individual fields.`, - }, - }, -}) - -const DontInferDirective = new GraphQLDirective({ - name: `dontInfer`, - description: `Do not infer additional fields for this type from nodes.`, - locations: [DirectiveLocation.OBJECT], - args: { - noDefaultResolvers: { - type: new GraphQLNonNull(GraphQLBoolean), - default: false, - description: `Don't add default resolvers to defined fields.`, - deprecationReason: `noDefaultResolvers is deprecated, annotate individual fields.`, - }, - }, -}) - -module.exports = { - InferDirective, - DontInferDirective, -} diff --git a/yarn.lock b/yarn.lock index 6df5fc28bffe3..f14038884b0c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10401,12 +10401,12 @@ graceful-fs@^4.1.15, graceful-fs@^4.1.9: resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= -graphql-compose@6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/graphql-compose/-/graphql-compose-6.0.3.tgz#fa5668a30694abef4166703aa03af07a741039a8" - integrity sha512-QpywEtNvlEQS0a5VIseMA/tk67QmEN9NNUx1B1tzGR/p7MePyus9wvci2cIP/mwdDrvLRRbwpmidSKQXFD3SEA== +graphql-compose@^6.3.2: + version "6.3.2" + resolved "https://registry.yarnpkg.com/graphql-compose/-/graphql-compose-6.3.2.tgz#0eff6e0f086c934af950db88b90a6667f879c59b" + integrity sha512-2sk4G3F/j7U4OBnPkB/HrE8Cejh8nHIJFBOGcqQvsELHXUHtx4S11zR0OU+J3cMtpE/2visBUGUhEHL9WlUK9A== dependencies: - graphql-type-json "^0.2.1" + graphql-type-json "^0.2.4" object-path "^0.11.4" graphql-config@^2.0.1: @@ -10458,10 +10458,10 @@ graphql-tools@^3.0.4: iterall "^1.1.3" uuid "^3.1.0" -graphql-type-json@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.2.1.tgz#d2c177e2f1b17d87f81072cd05311c0754baa420" - integrity sha1-0sF34vGxfYf4EHLNBTEcB1S6pCA= +graphql-type-json@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.2.4.tgz#545af27903e40c061edd30840a272ea0a49992f9" + integrity sha512-/tq02ayMQjrG4oDFDRLLrPk0KvJXue0nVXoItBe7uAdbNXjQUu+HYCBdAmPLQoseVzUKKMzrhq2P/sfI76ON6w== graphql@^14.1.1: version "14.1.1"