diff --git a/packages/gatsby-source-filesystem/src/create-file-node.js b/packages/gatsby-source-filesystem/src/create-file-node.js index cf28569ce4acf..4a1689d011471 100644 --- a/packages/gatsby-source-filesystem/src/create-file-node.js +++ b/packages/gatsby-source-filesystem/src/create-file-node.js @@ -39,6 +39,7 @@ exports.createFileNode = async (pathToFile, pluginOptions = {}) => { internal = { contentDigest, type: `Directory`, + description: `Directory "${path.relative(process.cwd(), slashed)}"`, } } else { const contentDigest = await md5File(slashedFile.absolutePath) @@ -46,6 +47,7 @@ exports.createFileNode = async (pathToFile, pluginOptions = {}) => { contentDigest, mediaType: mime.lookup(slashedFile.ext), type: `File`, + description: `File "${path.relative(process.cwd(), slashed)}"`, } } diff --git a/packages/gatsby-source-filesystem/src/create-remote-file-node.js b/packages/gatsby-source-filesystem/src/create-remote-file-node.js index c4c637d9de9ff..6637283aa4d6a 100644 --- a/packages/gatsby-source-filesystem/src/create-remote-file-node.js +++ b/packages/gatsby-source-filesystem/src/create-remote-file-node.js @@ -206,7 +206,7 @@ async function processRemoteNode({ url, store, cache, createNode, auth = {} }) { // Create the file node. const fileNode = await createFileNode(filename, {}) - + fileNode.internal.description = `File "${url}"` // Override the default plugin as gatsby-source-filesystem needs to // be the owner of File nodes or there'll be conflicts if any other // File nodes are created through normal usages of diff --git a/packages/gatsby/src/bootstrap/index.js b/packages/gatsby/src/bootstrap/index.js index 89adc18bde96a..e29201a217969 100644 --- a/packages/gatsby/src/bootstrap/index.js +++ b/packages/gatsby/src/bootstrap/index.js @@ -340,6 +340,8 @@ module.exports = async (args: BootstrapArgs) => { await require(`../schema`)() activity.end() + require(`../schema/type-conflict-reporter`).printConflicts() + // Extract queries activity = report.activityTimer(`extract queries from components`) activity.start() diff --git a/packages/gatsby/src/internal-plugins/internal-data-bridge/gatsby-node.js b/packages/gatsby/src/internal-plugins/internal-data-bridge/gatsby-node.js index 3335bfbbb7be3..1e0cfd173f4aa 100644 --- a/packages/gatsby/src/internal-plugins/internal-data-bridge/gatsby-node.js +++ b/packages/gatsby/src/internal-plugins/internal-data-bridge/gatsby-node.js @@ -156,6 +156,10 @@ exports.onCreatePage = ({ page, boundActionCreators }) => { .createHash(`md5`) .update(JSON.stringify(page)) .digest(`hex`), + description: + page.pluginCreatorId === `Plugin default-site-plugin` + ? `Your site's "gatsby-node.js"` + : page.pluginCreatorId, }, }) } diff --git a/packages/gatsby/src/joi-schemas/joi.js b/packages/gatsby/src/joi-schemas/joi.js index 878c5806f9f93..b40e3d0166f24 100644 --- a/packages/gatsby/src/joi-schemas/joi.js +++ b/packages/gatsby/src/joi-schemas/joi.js @@ -57,6 +57,7 @@ export const nodeSchema = Joi.object() owner: Joi.string().required(), fieldOwners: Joi.array(), content: Joi.string().allow(``), + description: Joi.string(), }), }) .unknown() diff --git a/packages/gatsby/src/redux/actions.js b/packages/gatsby/src/redux/actions.js index a8298caff8df0..5b9edce81a301 100644 --- a/packages/gatsby/src/redux/actions.js +++ b/packages/gatsby/src/redux/actions.js @@ -505,6 +505,10 @@ const typeOwners = {} * @param {string} node.internal.contentDigest the digest for the content * of this node. Helps Gatsby avoid doing extra work on data that hasn't * changed. + * @param {string} node.internal.description An optional field. Human + * readable description of what this node represent / its source. It will + * be displayed when type conflicts are found, making it easier to find + * and correct type conflicts. * @example * createNode({ * // Data for the node. @@ -525,6 +529,7 @@ const typeOwners = {} * .digest(`hex`), * mediaType: `text/markdown`, // optional * content: JSON.stringify(fieldData), // optional + * description: `Cool Service: "Title of entry"`, // optional * } * }) */ diff --git a/packages/gatsby/src/schema/__tests__/__snapshots__/data-tree-utils-test.js.snap b/packages/gatsby/src/schema/__tests__/__snapshots__/data-tree-utils-test.js.snap index 3155561a1d145..76b3f2f9b1ea0 100644 --- a/packages/gatsby/src/schema/__tests__/__snapshots__/data-tree-utils-test.js.snap +++ b/packages/gatsby/src/schema/__tests__/__snapshots__/data-tree-utils-test.js.snap @@ -35,6 +35,12 @@ Object { "name": Object { "field": "name", }, + "nestedArrays": Object { + "field": "nestedArrays", + }, + "objectsInArray": Object { + "field": "objectsInArray", + }, } `; @@ -45,21 +51,33 @@ Object { ], "context": Object { "nestedObject": Object { - "someOtherProperty": 3, + "someOtherProperty": 1, }, }, "date": "2006-07-22T22:39:53.000Z", "emptyArray": null, "frontmatter": Object { - "blue": 10010, + "blue": 100, "circle": "happy", "date": "2006-07-22T22:39:53.000Z", "draft": false, - "title": "The world of slash and adventure", + "title": "The world of dash and adventure", }, - "hair": 4, + "hair": 1, "iAmNull": null, "key-with..unsupported-values": true, - "name": "The Mad Wax", + "name": "The Mad Max", + "nestedArrays": Array [ + Array [ + 1, + ], + ], + "objectsInArray": Array [ + Object { + "field1": true, + "field2": 1, + "field3": "foo", + }, + ], } `; diff --git a/packages/gatsby/src/schema/__tests__/data-tree-utils-test.js b/packages/gatsby/src/schema/__tests__/data-tree-utils-test.js index 4e9bb5ff25238..0910e4a7f98da 100644 --- a/packages/gatsby/src/schema/__tests__/data-tree-utils-test.js +++ b/packages/gatsby/src/schema/__tests__/data-tree-utils-test.js @@ -1,10 +1,19 @@ const { - extractFieldExamples, + getExampleValues, buildFieldEnumValues, + clearTypeExampleValues, INVALID_VALUE, } = require(`../data-tree-utils`) +const { + typeConflictReporter, + TypeConflictEntry, +} = require(`../type-conflict-reporter`) describe(`Gatsby data tree utils`, () => { + beforeEach(() => { + clearTypeExampleValues() + }) + const nodes = [ { name: `The Mad Max`, @@ -13,6 +22,8 @@ describe(`Gatsby data tree utils`, () => { "key-with..unsupported-values": true, emptyArray: [], anArray: [1, 2, 3, 4], + nestedArrays: [[1, 2, 3], [4, 5, 6]], + objectsInArray: [{ field1: true }, { field2: 1 }], frontmatter: { date: `2006-07-22T22:39:53.000Z`, title: `The world of dash and adventure`, @@ -29,6 +40,8 @@ describe(`Gatsby data tree utils`, () => { emptyArray: [undefined, null], anArray: [1, 2, 5, 4], iAmNull: null, + nestedArrays: [[1, 2, 3]], + objectsInArray: [{ field3: `foo` }], frontmatter: { date: `2006-07-22T22:39:53.000Z`, title: `The world of slash and adventure`, @@ -83,15 +96,15 @@ describe(`Gatsby data tree utils`, () => { ] it(`builds field examples from an array of nodes`, () => { - expect(extractFieldExamples(nodes)).toMatchSnapshot() + expect(getExampleValues(nodes)).toMatchSnapshot() }) it(`null fields should have a null value`, () => { - expect(extractFieldExamples(nodes).iAmNull).toBeNull() + expect(getExampleValues(nodes).iAmNull).toBeNull() }) it(`should not mutate the nodes`, () => { - extractFieldExamples(nodes) + getExampleValues(nodes) expect(nodes[0].context.nestedObject).toBeNull() expect(nodes[1].context.nestedObject.someOtherProperty).toEqual(1) expect(nodes[2].context.nestedObject.someOtherProperty).toEqual(2) @@ -99,8 +112,8 @@ describe(`Gatsby data tree utils`, () => { }) it(`turns empty or sparse arrays to null`, () => { - expect(extractFieldExamples(nodes).emptyArray).toBeNull() - expect(extractFieldExamples(nodes).hair).toBeDefined() + expect(getExampleValues(nodes).emptyArray).toBeNull() + expect(getExampleValues(nodes).hair).toBeDefined() }) it(`build enum values for fields from array on nodes`, () => { @@ -108,7 +121,7 @@ describe(`Gatsby data tree utils`, () => { }) it(`turns polymorphic fields null`, () => { - let example = extractFieldExamples([ + let example = getExampleValues([ { foo: null }, { foo: [1] }, { foo: { field: 1 } }, @@ -117,7 +130,7 @@ describe(`Gatsby data tree utils`, () => { }) it(`handles polymorphic arrays`, () => { - let example = extractFieldExamples([ + let example = getExampleValues([ { foo: [[`foo`, `bar`]] }, { foo: [{ field: 1 }] }, ]) @@ -125,14 +138,14 @@ describe(`Gatsby data tree utils`, () => { }) it(`doesn't confuse empty fields for polymorhpic ones`, () => { - let example = extractFieldExamples([ + let example = getExampleValues([ { foo: { bar: 1 } }, { foo: null }, { foo: { field: 1 } }, ]) expect(example.foo).toEqual({ field: 1, bar: 1 }) - example = extractFieldExamples([ + example = getExampleValues([ { foo: [{ bar: 1 }] }, { foo: null }, { foo: [{ field: 1 }, { baz: 1 }] }, @@ -140,3 +153,114 @@ describe(`Gatsby data tree utils`, () => { expect(example.foo).toEqual([{ field: 1, bar: 1, baz: 1 }]) }) }) + +describe(`Type conflicts`, () => { + let addConflictSpy = jest.spyOn(typeConflictReporter, `addConflict`) + let addConflictExampleSpy = jest.spyOn( + TypeConflictEntry.prototype, + `addExample` + ) + + beforeEach(() => { + clearTypeExampleValues() + addConflictExampleSpy.mockReset() + }) + + afterAll(() => { + addConflictSpy.mockRestore() + addConflictExampleSpy.mockRestore() + }) + + it(`Doesn't report conflicts if there are none`, () => { + const nodes = [ + { + id: `id1`, + string: `string`, + number: 5, + boolean: true, + arrayOfStrings: [`string1`], + }, + { + id: `id2`, + string: `other string`, + number: 3.5, + boolean: false, + arrayOfStrings: null, + }, + ] + + getExampleValues({ nodes, type: `NoConflict` }) + + expect(addConflictExampleSpy).not.toBeCalled() + }) + + it(`Report type conflicts and its origin`, () => { + const nodes = [ + { + id: `id1`, + stringOrNumber: `string`, + number: 5, + boolean: true, + arrayOfStrings: [`string1`], + }, + { + id: `id2`, + stringOrNumber: 5, + number: 3.5, + boolean: false, + arrayOfStrings: null, + }, + ] + + getExampleValues({ nodes, type: `Conflict_1` }) + + expect(addConflictSpy).toBeCalled() + expect(addConflictSpy).toBeCalledWith( + `Conflict_1.stringOrNumber`, + expect.any(Array) + ) + + // expect(addConflictExampleSpy).toBeCalled() + expect(addConflictExampleSpy).toHaveBeenCalledTimes(2) + expect(addConflictExampleSpy).toBeCalledWith( + expect.objectContaining({ + value: nodes[0].stringOrNumber, + type: `string`, + parent: nodes[0], + }) + ) + expect(addConflictExampleSpy).toBeCalledWith( + expect.objectContaining({ + value: nodes[1].stringOrNumber, + type: `number`, + parent: nodes[1], + }) + ) + }) + + it(`Report conflict when array has mixed types and its origin`, () => { + const nodes = [ + { + id: `id1`, + arrayOfMixedType: [`string1`, 5, `string2`, true], + }, + ] + + getExampleValues({ nodes, type: `Conflict_2` }) + expect(addConflictSpy).toBeCalled() + expect(addConflictSpy).toBeCalledWith( + `Conflict_2.arrayOfMixedType`, + expect.any(Array) + ) + + expect(addConflictExampleSpy).toBeCalled() + expect(addConflictExampleSpy).toHaveBeenCalledTimes(1) + expect(addConflictExampleSpy).toBeCalledWith( + expect.objectContaining({ + value: nodes[0].arrayOfMixedType, + type: `array`, + parent: nodes[0], + }) + ) + }) +}) diff --git a/packages/gatsby/src/schema/__tests__/infer-graphql-input-type-test.js b/packages/gatsby/src/schema/__tests__/infer-graphql-input-type-test.js index f255076d0dde1..95d9f2b02d587 100644 --- a/packages/gatsby/src/schema/__tests__/infer-graphql-input-type-test.js +++ b/packages/gatsby/src/schema/__tests__/infer-graphql-input-type-test.js @@ -15,6 +15,7 @@ const { inferInputObjectStructureFromNodes, } = require(`../infer-graphql-input-fields`) const createSortField = require(`../create-sort-field`) +const { clearTypeExampleValues } = require(`../data-tree-utils`) function queryResult(nodes, query, { types = [] } = {}) { const nodeType = new GraphQLObjectType({ @@ -29,7 +30,7 @@ function queryResult(nodes, query, { types = [] } = {}) { nodeType, connectionFields: () => buildConnectionFields({ - name: `Test`, + name, nodes, nodeObjectType: nodeType, }), @@ -75,6 +76,10 @@ function queryResult(nodes, query, { types = [] } = {}) { return graphql(schema, query) } +beforeEach(() => { + clearTypeExampleValues() +}) + describe(`GraphQL Input args`, () => { const nodes = [ { diff --git a/packages/gatsby/src/schema/__tests__/infer-graphql-type-test.js b/packages/gatsby/src/schema/__tests__/infer-graphql-type-test.js index abc7c379bb1f7..1925327da932f 100644 --- a/packages/gatsby/src/schema/__tests__/infer-graphql-type-test.js +++ b/packages/gatsby/src/schema/__tests__/infer-graphql-type-test.js @@ -6,7 +6,7 @@ const { } = require(`graphql`) const path = require(`path`) const normalizePath = require(`normalize-path`) - +const { clearTypeExampleValues } = require(`../data-tree-utils`) const { inferObjectStructureFromNodes } = require(`../infer-graphql-type`) function queryResult(nodes, fragment, { types = [] } = {}) { @@ -48,6 +48,10 @@ function queryResult(nodes, fragment, { types = [] } = {}) { ) } +beforeEach(() => { + clearTypeExampleValues() +}) + describe(`GraphQL type inferance`, () => { const nodes = [ { diff --git a/packages/gatsby/src/schema/__tests__/node-tracking-test.js b/packages/gatsby/src/schema/__tests__/node-tracking-test.js index 2fdb575dbd665..096265b0ccefe 100644 --- a/packages/gatsby/src/schema/__tests__/node-tracking-test.js +++ b/packages/gatsby/src/schema/__tests__/node-tracking-test.js @@ -28,7 +28,7 @@ describe(`Track root nodes`, () => { require(`fs`).__setMockFiles(MOCK_FILE_INFO) const { getNode, getNodes } = require(`../../redux`) - const { findRootNode } = require(`../node-tracking`) + const { findRootNodeAncestor } = require(`../node-tracking`) const runSift = require(`../run-sift`) const buildNodeTypes = require(`../build-node-types`) const { boundActionCreators: { createNode } } = require(`../../redux/actions`) @@ -56,7 +56,7 @@ describe(`Track root nodes`, () => { it(`Tracks inline objects`, () => { const node = getNode(`id1`) const inlineObject = node.inlineObject - const trackedRootNode = findRootNode(inlineObject) + const trackedRootNode = findRootNodeAncestor(inlineObject) expect(trackedRootNode).toEqual(node) }) @@ -64,7 +64,7 @@ describe(`Track root nodes`, () => { it(`Tracks inline arrays`, () => { const node = getNode(`id1`) const inlineObject = node.inlineArray - const trackedRootNode = findRootNode(inlineObject) + const trackedRootNode = findRootNodeAncestor(inlineObject) expect(trackedRootNode).toEqual(node) }) @@ -72,7 +72,7 @@ describe(`Track root nodes`, () => { it(`Doesn't track copied objects`, () => { const node = getNode(`id1`) const copiedInlineObject = { ...node.inlineObject } - const trackedRootNode = findRootNode(copiedInlineObject) + const trackedRootNode = findRootNodeAncestor(copiedInlineObject) expect(trackedRootNode).not.toEqual(node) }) @@ -82,7 +82,7 @@ describe(`Track root nodes`, () => { it(`Tracks inline objects`, () => { const node = getNode(`id2`) const inlineObject = node.inlineObject - const trackedRootNode = findRootNode(inlineObject) + const trackedRootNode = findRootNodeAncestor(inlineObject) expect(trackedRootNode).toEqual(node) }) @@ -105,10 +105,10 @@ describe(`Track root nodes`, () => { }) expect(result.edges.length).toEqual(2) - expect(findRootNode(result.edges[0].node.inlineObject)).toEqual( + expect(findRootNodeAncestor(result.edges[0].node.inlineObject)).toEqual( result.edges[0].node ) - expect(findRootNode(result.edges[1].node.inlineObject)).toEqual( + expect(findRootNodeAncestor(result.edges[1].node.inlineObject)).toEqual( result.edges[1].node ) }) @@ -130,7 +130,7 @@ describe(`Track root nodes`, () => { }) expect(result.edges.length).toEqual(1) - expect(findRootNode(result.edges[0].node.inlineObject)).toEqual( + expect(findRootNodeAncestor(result.edges[0].node.inlineObject)).toEqual( result.edges[0].node ) }) diff --git a/packages/gatsby/src/schema/build-connection-fields.js b/packages/gatsby/src/schema/build-connection-fields.js index 1e3e42bec813e..84b265164fb69 100644 --- a/packages/gatsby/src/schema/build-connection-fields.js +++ b/packages/gatsby/src/schema/build-connection-fields.js @@ -15,7 +15,10 @@ const { const { buildFieldEnumValues } = require(`./data-tree-utils`) module.exports = type => { - const enumValues = buildFieldEnumValues(type.nodes) + const enumValues = buildFieldEnumValues({ + nodes: type.nodes, + type: type.name, + }) const { connectionType: groupConnection } = connectionDefinitions({ name: _.camelCase(`${type.name} groupConnection`), nodeType: type.nodeObjectType, diff --git a/packages/gatsby/src/schema/build-node-types.js b/packages/gatsby/src/schema/build-node-types.js index 54852ec4f564e..cb52d8fdad859 100644 --- a/packages/gatsby/src/schema/build-node-types.js +++ b/packages/gatsby/src/schema/build-node-types.js @@ -19,6 +19,10 @@ const { nodeInterface } = require(`./node-interface`) const { getNodes, getNode, getNodeAndSavePathDependency } = require(`../redux`) const { createPageDependency } = require(`../redux/actions/add-page-dependency`) const { setFileNodeRootType } = require(`./types/type-file`) +const { + clearTypeExampleValues, + getExampleValues, +} = require(`./data-tree-utils`) import type { ProcessedNodeType } from "./infer-graphql-type" @@ -28,6 +32,8 @@ module.exports = async () => { const types = _.groupBy(getNodes(), node => node.internal.type) const processedTypes: TypeMap = {} + clearTypeExampleValues() + // Reset stored File type to not point to outdated type definition setFileNodeRootType(null) @@ -112,7 +118,7 @@ module.exports = async () => { const inferredFields = inferObjectStructureFromNodes({ nodes: type.nodes, types: _.values(processedTypes), - allNodes: getNodes(), + exampleValue: getExampleValues({ type: type.name, nodes: type.nodes }), }) return { diff --git a/packages/gatsby/src/schema/data-tree-utils.js b/packages/gatsby/src/schema/data-tree-utils.js index 601a4c46cdf6a..3d36c3f281abf 100644 --- a/packages/gatsby/src/schema/data-tree-utils.js +++ b/packages/gatsby/src/schema/data-tree-utils.js @@ -4,17 +4,11 @@ const flatten = require(`flat`) const typeOf = require(`type-of`) const createKey = require(`./create-key`) +const { typeConflictReporter } = require(`./type-conflict-reporter`) const INVALID_VALUE = Symbol(`INVALID_VALUE`) const isDefined = v => v != null -const isSameType = (a, b) => a == null || b == null || typeOf(a) === typeOf(b) -const areAllSameType = list => - list.every((current, i) => { - let prev = i ? list[i - 1] : undefined - return isSameType(prev, current) - }) - const isEmptyObjectOrArray = (obj: any): boolean => { if (obj === INVALID_VALUE) { return true @@ -37,6 +31,107 @@ const isEmptyObjectOrArray = (obj: any): boolean => { return false } +const isScalar = val => !_.isObject(val) || val instanceof Date + +const extractTypes = value => { + if (_.isArray(value)) { + const uniqueTypes = _.uniq( + value.filter(isDefined).map(item => extractTypes(item).type) + ).sort() + return { + type: `array<${uniqueTypes.join(`|`)}>`, + arrayTypes: uniqueTypes, + } + } else { + const type = typeOf(value) + return { + type, + arrayTypes: [], + } + } +} + +const getExampleScalarFromArray = values => + _.reduce( + values, + (value, nextValue) => { + // Prefer floats over ints as they're more specific. + if (value && _.isNumber(value) && !_.isInteger(value)) { + return value + } else if (value === null) { + return nextValue + } else { + return value + } + }, + null + ) + +const extractFromEntries = (entries, selector, key = null) => { + const entriesOfUniqueType = _.uniqBy(entries, entry => entry.type) + + if (entriesOfUniqueType.length == 0) { + // skip if no defined types + return null + } else if ( + entriesOfUniqueType.length > 1 || + entriesOfUniqueType[0].arrayTypes.length > 1 + ) { + // there is multiple types or array of multiple types + if (selector) { + typeConflictReporter.addConflict(selector, entriesOfUniqueType) + } + return INVALID_VALUE + } + + // Now we have entries of single type, we can merge them + const values = entries.map(entry => entry.value) + + const exampleValue = entriesOfUniqueType[0].value + if (isScalar(exampleValue)) { + return getExampleScalarFromArray(values) + } else if (_.isObject(exampleValue)) { + if (_.isArray(exampleValue)) { + const concatanedItems = _.concat(...values) + // Linked node arrays don't get reduced further as we + // want to preserve all the linked node types. + if (_.includes(key, `___NODE`)) { + return concatanedItems + } + + return extractFromArrays(concatanedItems, entries, selector) + } else if (_.isPlainObject(exampleValue)) { + return extractFieldExamples(values, selector) + } + } + // unsuported object + return INVALID_VALUE +} + +const extractFromArrays = (values, entries, selector) => { + const filteredItems = values.filter(isDefined) + if (filteredItems.length === 0) { + return null + } + if (isScalar(filteredItems[0])) { + return [getExampleScalarFromArray(filteredItems)] + } + + const flattenEntries = _.flatten( + entries.map(entry => + entry.value.map(value => { + return { + value, + parent: entry.parent, + ...extractTypes(value), + } + }) + ) + ) + + return [extractFromEntries(flattenEntries, `${selector}[]`)] +} + /** * Takes an array of source nodes and returns a pristine * example that can be used to infer types. @@ -49,52 +144,44 @@ const isEmptyObjectOrArray = (obj: any): boolean => { * * @param {*Nodes} args */ -const extractFieldExamples = (nodes: any[]) => - // $FlowFixMe - _.mergeWith( - _.isArray(nodes[0]) ? [] : {}, - ..._.cloneDeep(nodes), - (obj, next, key, po, pn, stack) => { - if (obj === INVALID_VALUE) return obj - - // TODO: if you want to support infering Union types this should be handled - // differently. Maybe merge all like types into examples for each type? - // e.g. union: [1, { foo: true }, ['brown']] -> Union Int|Object|List - if (!isSameType(obj, next)) { - return INVALID_VALUE - } +const extractFieldExamples = (nodes: object[], selector: string) => { + // get list of keys in all nodes + const allKeys = _.uniq(_.flatten(nodes.map(_.keys))) - if (!_.isArray(obj || next)) { - // Prefer floats over ints as they're more specific. - if (obj && _.isNumber(obj) && !_.isInteger(obj)) return obj - if (obj === null) return next - if (next === null) return obj - return undefined - } + return _.zipObject( + allKeys, + allKeys.map(key => { + const nextSelector = selector && `${selector}.${key}` - let array = [].concat(obj, next).filter(isDefined) + const nodeWithValues = nodes.filter(node => { + if (!node) return false - if (!array.length) return null - if (!areAllSameType(array)) return INVALID_VALUE + const value = node[key] + if (_.isObject(value)) { + return !isEmptyObjectOrArray(value) + } else { + return isDefined(value) + } + }) - // Linked node arrays don't get reduced further as we - // want to preserve all the linked node types. - if (_.includes(key, `___NODE`)) { - return array - } + // we want to keep track of nodes as we need it to get origin of data + const entries = nodeWithValues.map(node => { + const value = node[key] + return { + value, + parent: node, + ...extractTypes(value), + } + }) - // primitive values and dates don't get merged further, just take the first item - if (!_.isObject(array[0]) || array[0] instanceof Date) { - return array.slice(0, 1) - } - let merged = extractFieldExamples(array) - return isDefined(merged) ? [merged] : null - } + return extractFromEntries(entries, nextSelector, key) + }) ) +} -const buildFieldEnumValues = (nodes: any[]) => { +const buildFieldEnumValues = arg => { const enumValues = {} - const values = flatten(extractFieldExamples(nodes), { + const values = flatten(getExampleValues(arg), { maxDepth: 3, safe: true, // don't flatten arrays. delimiter: `___`, @@ -110,8 +197,8 @@ const buildFieldEnumValues = (nodes: any[]) => { // extract a list of field names // nested objects get flattened to "outer___inner" which will be converted back to // "outer.inner" by run-sift -const extractFieldNames = (nodes: any[]) => { - const values = flatten(extractFieldExamples(nodes), { +const extractFieldNames = arg => { + const values = flatten(getExampleValues(arg), { maxDepth: 3, safe: true, // don't flatten arrays. delimiter: `___`, @@ -120,10 +207,57 @@ const extractFieldNames = (nodes: any[]) => { return Object.keys(values) } +let typeExampleValues = {} + +const clearTypeExampleValues = () => { + typeExampleValues = {} + typeConflictReporter.clearConflicts() +} + +const getNodesAndTypeFromArg = arg => { + let type, nodes + + if (_.isPlainObject(arg)) { + type = arg.type + nodes = arg.nodes + } else if (_.isArray(arg)) { + nodes = arg + if (nodes.length > 0 && nodes[0].internal) { + type = nodes[0].internal.type + } + } else if (_.isString) { + type = arg + } + + return { type, nodes } +} + +const getExampleValues = arg => { + const { type, nodes } = getNodesAndTypeFromArg(arg) + + // if type is defined and is in example value cache return it + if (type && type in typeExampleValues) { + return typeExampleValues[type] + } + + // if nodes were passed extract field example from it + if (nodes && nodes.length > 0) { + const exampleValue = extractFieldExamples(nodes, type) + // if type is set - cache results + if (type) { + typeExampleValues[type] = exampleValue + } + return exampleValue + } + + return {} +} + module.exports = { INVALID_VALUE, - extractFieldExamples, buildFieldEnumValues, extractFieldNames, isEmptyObjectOrArray, + clearTypeExampleValues, + getExampleValues, } diff --git a/packages/gatsby/src/schema/infer-graphql-input-fields.js b/packages/gatsby/src/schema/infer-graphql-input-fields.js index 59524d647f632..95508d2256c00 100644 --- a/packages/gatsby/src/schema/infer-graphql-input-fields.js +++ b/packages/gatsby/src/schema/infer-graphql-input-fields.js @@ -14,7 +14,7 @@ const typeOf = require(`type-of`) const createTypeName = require(`./create-type-name`) const createKey = require(`./create-key`) const { - extractFieldExamples, + getExampleValues, extractFieldNames, isEmptyObjectOrArray, } = require(`./data-tree-utils`) @@ -212,12 +212,15 @@ export function inferInputObjectStructureFromNodes({ nodes, typeName = ``, prefix = ``, - exampleValue = extractFieldExamples(nodes), + exampleValue = null, }: InferInputOptions): Object { const inferredFields = {} const isRoot = !prefix prefix = isRoot ? typeName : prefix + if (exampleValue === null) { + exampleValue = getExampleValues(nodes) + } _.each(exampleValue, (v, k) => { let value = v @@ -238,7 +241,10 @@ export function inferInputObjectStructureFromNodes({ const relatedNodes = getNodes().filter( node => node.internal.type === linkedNode.internal.type ) - value = extractFieldExamples(relatedNodes) + value = getExampleValues({ + nodes: relatedNodes, + type: linkedNode.internal.type, + }) value = recursiveOmitBy(value, (_v, _k) => _.includes(_k, `___NODE`)) linkedNodeCache[linkedNode.internal.type] = value } diff --git a/packages/gatsby/src/schema/infer-graphql-type.js b/packages/gatsby/src/schema/infer-graphql-type.js index 2479abe5c261f..b310b073c74ef 100644 --- a/packages/gatsby/src/schema/infer-graphql-type.js +++ b/packages/gatsby/src/schema/infer-graphql-type.js @@ -16,10 +16,7 @@ const { store, getNode, getNodes } = require(`../redux`) const { createPageDependency } = require(`../redux/actions/add-page-dependency`) const createTypeName = require(`./create-type-name`) const createKey = require(`./create-key`) -const { - extractFieldExamples, - isEmptyObjectOrArray, -} = require(`./data-tree-utils`) +const { getExampleValues, isEmptyObjectOrArray } = require(`./data-tree-utils`) const DateType = require(`./types/type-date`) const FileType = require(`./types/type-file`) @@ -316,7 +313,7 @@ export function inferObjectStructureFromNodes({ nodes, types, selector, - exampleValue = extractFieldExamples(nodes), + exampleValue = null, }: inferTypeOptions): GraphQLFieldConfigMap<*, *> { const config = store.getState().config const isRoot = !selector @@ -325,6 +322,11 @@ export function inferObjectStructureFromNodes({ // Ensure nodes have internal key with object. nodes = nodes.map(n => (n.internal ? n : { ...n, internal: {} })) + const nodeTypeName = nodes[0].internal.type + if (exampleValue === null) { + exampleValue = getExampleValues({ type: nodeTypeName, nodes }) + } + const inferredFields = {} _.each(exampleValue, (value, key) => { // Remove fields common to the top-level of all nodes. We add these @@ -334,7 +336,7 @@ export function inferObjectStructureFromNodes({ // Several checks to see if a field is pointing to custom type // before we try automatic inference. const nextSelector = selector ? `${selector}.${key}` : key - const fieldSelector = `${nodes[0].internal.type}.${nextSelector}` + const fieldSelector = `${nodeTypeName}.${nextSelector}` let fieldName = key let inferredField diff --git a/packages/gatsby/src/schema/node-tracking.js b/packages/gatsby/src/schema/node-tracking.js index 7af6b0a27711c..c4ab1282cdf8c 100644 --- a/packages/gatsby/src/schema/node-tracking.js +++ b/packages/gatsby/src/schema/node-tracking.js @@ -55,14 +55,17 @@ exports.trackInlineObjectsInRootNode = trackInlineObjectsInRootNode /** * Finds top most ancestor of node that contains passed Object or Array * @param {(Object|Array)} obj Object/Array belonging to Node object or Node object - * @returns {Node} Top most ancestor + * @param {nodePredicate} [predicate] Optional callback to check if ancestor meets defined conditions + * @returns {Node} Top most ancestor if predicate is not specified + * or first node that meet predicate conditions if predicate is specified */ -function findRootNode(obj) { +const findRootNodeAncestor = (obj, predicate = null) => { // Find the root node. let rootNode = obj let whileCount = 0 let rootNodeId while ( + (!predicate || !predicate(rootNode)) && (rootNodeId = getRootNodeId(rootNode) || rootNode.parent) && (getNode(rootNode.parent) !== undefined || getNode(rootNodeId)) && whileCount < 101 @@ -81,10 +84,15 @@ function findRootNode(obj) { } } - return rootNode + return !predicate || predicate(rootNode) ? rootNode : null } -exports.findRootNode = findRootNode +/** + * @callback nodePredicate + * @param {Node} node Node that is examined + */ + +exports.findRootNodeAncestor = findRootNodeAncestor // Track nodes that are already in store _.each(getNodes(), node => { diff --git a/packages/gatsby/src/schema/type-conflict-reporter.js b/packages/gatsby/src/schema/type-conflict-reporter.js new file mode 100644 index 0000000000000..df5f37cc7876c --- /dev/null +++ b/packages/gatsby/src/schema/type-conflict-reporter.js @@ -0,0 +1,133 @@ +// @flow +const _ = require(`lodash`) +const report = require(`gatsby-cli/lib/reporter`) +const typeOf = require(`type-of`) +const util = require(`util`) +const { findRootNodeAncestor } = require(`./node-tracking`) + +const isNodeWithDescription = node => + node && node.internal && node.internal.description + +const findNodeDescription = obj => { + if (obj) { + const node = findRootNodeAncestor(obj, isNodeWithDescription) + if (isNodeWithDescription(node)) { + return node.internal.description + } + } + return `` +} + +const formatValue = value => { + if (!_.isArray(value)) { + return util.inspect(value, { + colors: true, + depth: 0, + breakLength: Infinity, + }) + } + + let wasElipsisLast = false + const usedTypes = [] + const output = [] + + value.forEach(item => { + const type = typeOf(item) + if (usedTypes.indexOf(type) !== -1) { + if (!wasElipsisLast) { + output.push(`...`) + wasElipsisLast = true + } + } else { + output.push(formatValue(item)) + wasElipsisLast = false + usedTypes.push(type) + } + }) + + return `[ ${output.join(`, `)} ]` +} + +class TypeConflictEntry { + constructor(selector) { + this.selector = selector + this.types = {} + } + + addExample({ value, type, parent }) { + this.types[type] = { + value, + description: findNodeDescription(parent), + } + } + + printEntry() { + const sortedByTypeName = _.sortBy( + _.entries(this.types), + ([typeName, value]) => typeName + ) + + report.log( + `${this.selector}:${sortedByTypeName + .map( + ([typeName, { value, description }]) => + `\n - type: ${typeName}\n value: ${formatValue( + value + )}${description && `\n source: ${description}`}` + ) + .join(``)}` + ) + } +} + +class TypeConflictReporter { + constructor() { + this.clearConflicts() + } + + clearConflicts() { + this.entries = {} + } + + getFromSelector(selector) { + if (this.entries[selector]) { + return this.entries[selector] + } + + const dataEntry = new TypeConflictEntry(selector) + this.entries[selector] = dataEntry + return dataEntry + } + + addConflict(selector, examples) { + if (selector.substring(0, 11) === `SitePlugin.`) { + // Don't store and print out type conflicts in plugins. + // This is out of user control so he can't do anything + // to hide those. + return + } + + const entry = this.getFromSelector(selector) + examples + .filter(example => example.value != null) + .forEach(example => entry.addExample(example)) + } + + printConflicts() { + const entries = _.values(this.entries) + if (entries.length > 0) { + report.warn( + `There are conflicting field types in your data. GraphQL schema will omit those fields.` + ) + entries.forEach(entry => entry.printEntry()) + } + } +} + +const typeConflictReporter = new TypeConflictReporter() + +const printConflicts = () => { + typeConflictReporter.printConflicts() +} + +module.exports = { typeConflictReporter, printConflicts, TypeConflictEntry } diff --git a/packages/gatsby/src/schema/types/type-file.js b/packages/gatsby/src/schema/types/type-file.js index 7a869ab9f6b51..3cdd9ab3a5dc8 100644 --- a/packages/gatsby/src/schema/types/type-file.js +++ b/packages/gatsby/src/schema/types/type-file.js @@ -7,7 +7,7 @@ const normalize = require(`normalize-path`) const systemPath = require(`path`) const { getNodes } = require(`../../redux`) -const { findRootNode } = require(`../node-tracking`) +const { findRootNodeAncestor } = require(`../node-tracking`) const { createPageDependency, } = require(`../../redux/actions/add-page-dependency`) @@ -99,7 +99,7 @@ function pointsToFile(nodes, key, value) { } } - const rootNode = findRootNode(node) + const rootNode = findRootNodeAncestor(node) // Only nodes transformed (ultimately) from a File // can link to another File. @@ -164,7 +164,7 @@ function createType(fileNodeRootType, isArray) { // Find the File node for this node (we assume the node is something // like markdown which would be a child node of a File node). - const parentFileNode = findRootNode(node) + const parentFileNode = findRootNodeAncestor(node) // Find the linked File node(s) if (isArray) { diff --git a/www/gatsby-node.js b/www/gatsby-node.js index 198514ca5b0fd..a390009b5e057 100644 --- a/www/gatsby-node.js +++ b/www/gatsby-node.js @@ -125,9 +125,9 @@ exports.createPages = ({ graphql, boundActionCreators }) => { // Create blog pages. blogPosts.forEach((edge, index) => { - const next = index === 0 ? false : blogPosts[index - 1].node + const next = index === 0 ? null : blogPosts[index - 1].node const prev = - index === blogPosts.length - 1 ? false : blogPosts[index + 1].node + index === blogPosts.length - 1 ? null : blogPosts[index + 1].node createPage({ path: `${edge.node.fields.slug}`, // required