diff --git a/index.js b/index.js index 34e3df1e..67d6a7dc 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,62 @@ var parse = require('./parser').parse; -var cache = {}; +// Strip insignificant whitespace +// Note that this could do a lot more, such as reorder fields etc. +function normalize(string) { + return string.replace(/[\s,]+/g, ' ').trim(); +} + +// A map docString -> graphql document +var docCache = {}; + +// A map fragmentName -> [normalized source] +var fragmentSourceMap = {}; + +function cacheKeyFromLoc(loc) { + return normalize(loc.source.body.substring(loc.start, loc.end)); +} + +// For testing. +function resetCaches() { + docCache = {}; + fragmentSourceMap = {}; +} + +// Take a unstripped parsed document (query/mutation or even fragment), and +// check all fragment definitions, checking for name->source uniqueness +var printFragmentWarnings = true; +function checkFragments(ast) { + for (var i = 0; i < ast.definitions.length; i++) { + var fragmentDefinition = ast.definitions[i]; + if (fragmentDefinition.kind === 'FragmentDefinition') { + var fragmentName = fragmentDefinition.name.value; + var sourceKey = cacheKeyFromLoc(fragmentDefinition.loc); + + // We know something about this fragment + if (fragmentSourceMap.hasOwnProperty(fragmentName) && + !fragmentSourceMap[fragmentName][sourceKey]) { + + // this is a problem because the app developer is trying to register another fragment with + // the same name as one previously registered. So, we tell them about it. + if (printFragmentWarnings) { + console.warn("Warning: fragment with name " + fragmentName + " already exists.\n" + + "graphql-tag enforces all fragment names across your application to be unique; read more about\n" + + "this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names"); + } + + fragmentSourceMap[fragmentName][sourceKey] = true; + + } else if (!fragmentSourceMap.hasOwnProperty(fragmentName)) { + fragmentSourceMap[fragmentName] = {}; + fragmentSourceMap[fragmentName][sourceKey] = true; + } + } + } +} + +function disableFragmentWarnings() { + printFragmentWarnings = false; +} function stripLoc (doc, removeLocAtThisLevel) { var docType = Object.prototype.toString.call(doc); @@ -39,19 +95,22 @@ function stripLoc (doc, removeLocAtThisLevel) { } function parseDocument(doc) { - if (cache[doc]) { - return cache[doc]; + var cacheKey = normalize(doc); + + if (docCache[cacheKey]) { + return docCache[cacheKey]; } var parsed = parse(doc); - if (!parsed || parsed.kind !== 'Document') { throw new Error('Not a valid GraphQL document.'); } + // check that all "new" fragments inside the documents are consistent with + // existing fragments of the same name + checkFragments(parsed); parsed = stripLoc(parsed, false); - - cache[doc] = parsed; + docCache[cacheKey] = parsed; return parsed; } @@ -80,5 +139,7 @@ function gql(/* arguments */) { // Support typescript, which isn't as nice as Babel about default exports gql.default = gql; +gql.resetCaches = resetCaches; +gql.disableFragmentWarnings = disableFragmentWarnings; module.exports = gql; diff --git a/test.js b/test.js index 7eebe4e4..808b0c80 100644 --- a/test.js +++ b/test.js @@ -20,6 +20,10 @@ const assert = require('chai').assert; assert.isTrue(gql`{ sameQuery }` === gql`{ sameQuery }`); }); + it('returns the same object for the same query, even with whitespace differences', () => { + assert.isTrue(gql`{ sameQuery }` === gql` { sameQuery, }`); + }); + it('is correct for a simple query', () => { const ast = gql` { @@ -30,80 +34,70 @@ const assert = require('chai').assert; } `; - assert.deepEqual(ast, { - "kind": "Document", - "loc": { - "start": 9, - "end": 106, - "source": { - "name": "GraphQL", - "body": "\n {\n user(id: 5) {\n firstName\n lastName\n }\n }\n " - } - }, - "definitions": [ - { - "kind": "OperationDefinition", - "operation": "query", - "name": null, - "variableDefinitions": null, - "directives": [], - "selectionSet": { - "kind": "SelectionSet", - "selections": [ - { - "kind": "Field", - "alias": null, - "name": { - "kind": "Name", - "value": "user" - }, - "arguments": [ + assert.equal(ast.kind, "Document"); + assert.deepEqual(ast.definitions, [ + { + "kind": "OperationDefinition", + "operation": "query", + "name": null, + "variableDefinitions": null, + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "alias": null, + "name": { + "kind": "Name", + "value": "user" + }, + "arguments": [ + { + "kind": "Argument", + "name": { + "kind": "Name", + "value": "id" + }, + "value": { + "kind": "IntValue", + "value": "5" + } + } + ], + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ { - "kind": "Argument", + "kind": "Field", + "alias": null, "name": { "kind": "Name", - "value": "id" + "value": "firstName" }, - "value": { - "kind": "IntValue", - "value": "5" - } - } - ], - "directives": [], - "selectionSet": { - "kind": "SelectionSet", - "selections": [ - { - "kind": "Field", - "alias": null, - "name": { - "kind": "Name", - "value": "firstName" - }, - "arguments": [], - "directives": [], - "selectionSet": null + "arguments": [], + "directives": [], + "selectionSet": null + }, + { + "kind": "Field", + "alias": null, + "name": { + "kind": "Name", + "value": "lastName" }, - { - "kind": "Field", - "alias": null, - "name": { - "kind": "Name", - "value": "lastName" - }, - "arguments": [], - "directives": [], - "selectionSet": null - } - ] - } + "arguments": [], + "directives": [], + "selectionSet": null + } + ] } - ] - } + } + ] } - ] - }) + } + ]); }); it('returns the same object for the same fragment', () => { @@ -119,68 +113,59 @@ const assert = require('chai').assert; } `; - assert.deepEqual(ast, { - "kind": "Document", - "loc": { - "start": 9, - "end": 96, - "source": { - "name": "GraphQL", - "body": "\n fragment UserFragment on User {\n firstName\n lastName\n }\n " - } - }, - "definitions": [ - { - "kind": "FragmentDefinition", + assert.equal(ast.kind, "Document"); + assert.deepEqual(ast.definitions, [ + { + "kind": "FragmentDefinition", + "name": { + "kind": "Name", + "value": "UserFragment" + }, + "typeCondition": { + kind: "NamedType", "name": { "kind": "Name", - "value": "UserFragment" - }, - "typeCondition": { - kind: "NamedType", - "name": { - "kind": "Name", - "value": "User" - } - }, - "directives": [], - "selectionSet": { - "kind": "SelectionSet", - "selections": [ - { - "kind": "Field", - "alias": null, - "name": { - "kind": "Name", - "value": "firstName" - }, - "arguments": [], - "directives": [], - "selectionSet": null - }, - { - "kind": "Field", - "alias": null, - "name": { - "kind": "Name", - "value": "lastName" - }, - "arguments": [], - "directives": [], - "selectionSet": null - } - ] + "value": "User" } + }, + "directives": [], + "selectionSet": { + "kind": "SelectionSet", + "selections": [ + { + "kind": "Field", + "alias": null, + "name": { + "kind": "Name", + "value": "firstName" + }, + "arguments": [], + "directives": [], + "selectionSet": null + }, + { + "kind": "Field", + "alias": null, + "name": { + "kind": "Name", + "value": "lastName" + }, + "arguments": [], + "directives": [], + "selectionSet": null + } + ] } - ] - }); + } + ]); }); - // NOTE extra spaces to line up with the fragement below - const fragmentAst = gql`fragment UserFragment on User { - firstName - lastName - }`; + const fragmentAst = gql` + fragment UserFragment on User { + firstName + lastName + } + `; it('can reference a fragment passed as a document', () => { const ast = gql` { @@ -204,7 +189,7 @@ const assert = require('chai').assert; `); }); - it('returns the same object for the same document with sub', () => { + it('returns the same object for the same document with substitution', () => { // We know that calling `gql` on a fragment string will always return // the same document, so we can reuse `fragmentAst` assert.isTrue(gql`{ ...UserFragment } ${fragmentAst}` === @@ -212,10 +197,13 @@ const assert = require('chai').assert; }); it('can reference a fragment that references as fragment', () => { - const secondFragmentAst = gql`fragment SecondUserFragment on User { + const secondFragmentAst = gql` + fragment SecondUserFragment on User { ...UserFragment } - ${fragmentAst}`; + ${fragmentAst} + `; + const ast = gql` { user(id: 5) { @@ -241,6 +229,52 @@ const assert = require('chai').assert; `); }); + describe('fragment warnings', () => { + let warnings = []; + const oldConsoleWarn = console.warn; + beforeEach(() => { + gqlRequire.resetCaches(); + warnings = []; + console.warn = (w) => warnings.push(w); + }); + afterEach(() => { + console.warn = oldConsoleWarn; + }); + + it('warns if you use the same fragment name for different fragments', () => { + const frag1 = gql`fragment TestSame on Bar { fieldOne }`; + const frag2 = gql`fragment TestSame on Bar { fieldTwo }`; + + assert.isFalse(frag1 === frag2); + assert.equal(warnings.length, 1); + }); + + it('does not warn if you use the same fragment name for the same fragment', () => { + const frag1 = gql`fragment TestDifferent on Bar { fieldOne }`; + const frag2 = gql`fragment TestDifferent on Bar { fieldOne }`; + + assert.isTrue(frag1 === frag2); + assert.equal(warnings.length, 0); + }); + + it('does not warn if you use the same embedded fragment in two different queries', () => { + const frag1 = gql`fragment TestEmbedded on Bar { field }`; + const query1 = gql`{ bar { fieldOne ...TestEmbedded } } ${frag1}`; + const query2 = gql`{ bar { fieldTwo ...TestEmbedded } } ${frag1}`; + + assert.isFalse(query1 === query2); + assert.equal(warnings.length, 0); + }); + + it('does not warn if you use the same fragment name for embedded and non-embedded fragments', () => { + const frag1 = gql`fragment TestEmbeddedTwo on Bar { field }`; + const query1 = gql`{ bar { ...TestEmbedded } } ${frag1}`; + const query2 = gql`{ bar { ...TestEmbedded } } fragment TestEmbeddedTwo on Bar { field }`; + + assert.equal(warnings.length, 0); + }); + }); + // How to make this work? // it.only('can reference a fragment passed as a document via shorthand', () => { // const ast = gql`