diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a5c6157 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,26 @@ +name: CI + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: denolib/setup-deno@v2 + with: + deno-version: v1.8 + - name: Run tests + run: deno test --unstable --coverage=coverage + - name: Create coverage report + run: deno --unstable coverage ./coverage --lcov > coverage.lcov + - name: Collect coverage + uses: codecov/codecov-action@v1.0.10 + with: + file: ./coverage.lcov diff --git a/README.md b/README.md index dfafb42..d329ccb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ # graphql-tag -🦕 Deno port of `graphql-tag` library. +[![GitHub release (latest by date)][releases]][releases-page] [![GitHub Workflow Status][gh-actions-img]][github-actions] +[![Codecov][codecov-badge]][codecov] [![][docs-badge]][docs] + +> 🦕 Deno port of [graphql-tag](https://github.com/apollographql/graphql-tag) library. + +Create a GraphQL schema AST from template literal. + +## Example + +```ts +import { buildASTSchema, graphql } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts' +import { gql } from 'https://deno.land/x/graphql_tag/mod.ts' + +const typeDefs = gql` + type Query { + hello: String + } +` + +const query = `{ hello }` + +const resolvers = { hello: () => 'world' } + +const schema = buildASTSchema(typeDefs) + +console.log(await graphql(schema, query, resolvers)) +``` + +[releases]: https://img.shields.io/github/v/release/deno-libs/graphql-tag?style=flat-square +[docs-badge]: https://img.shields.io/github/v/release/deno-libs/graphql_tag?color=yellow&label=Documentation&logo=deno&style=flat-square +[docs]: https://doc.deno.land/https/deno.land/x/graphql_tag/mod.ts +[releases-page]: https://github.com/deno-libs/graphql-tag/releases +[gh-actions-img]: https://img.shields.io/github/workflow/status/deno-libs/graphql-tag/CI?style=flat-square +[codecov]: https://codecov.io/gh/deno-libs/graphql-tag +[github-actions]: https://github.com/deno-libs/graphql-tag/actions +[codecov-badge]: https://img.shields.io/codecov/c/gh/deno-libs/graphql-tag?style=flat-square + +## Donate + +[![PayPal](https://img.shields.io/badge/PayPal-cyan?style=flat-square&logo=paypal)](https://paypal.me/v1rtl) [![ko-fi](https://img.shields.io/badge/kofi-pink?style=flat-square&logo=ko-fi)](https://ko-fi.com/v1rtl) [![Qiwi](https://img.shields.io/badge/qiwi-white?style=flat-square&logo=qiwi)](https://qiwi.com/n/V1RTL) [![Yandex Money](https://img.shields.io/badge/Yandex_Money-yellow?style=flat-square&logo=yandex)](https://money.yandex.ru/to/410014774355272) + +[![Bitcoin](https://badge-crypto.vercel.app/api/badge?coin=btc&address=3PxedDftWBXujWtr7TbWQSiYTsZJoMD8K5)](https://badge-crypto.vercel.app/btc/3PxedDftWBXujWtr7TbWQSiYTsZJoMD8K5) [![Ethereum](https://badge-crypto.vercel.app/api/badge?coin=eth&address=0x9d9236DC024958D7fB73Ad9B178BD5D372D82288) +](https://badge-crypto.vercel.app/eth/0x9d9236DC024958D7fB73Ad9B178BD5D372D82288) [![ChainLink](https://badge-crypto.vercel.app/api/badge?coin=link&address=0x9d9236DC024958D7fB73Ad9B178BD5D372D82288)](https://badge-crypto.vercel.app/link/0xcd0da1c9b0DA7D2b862bbF813cB50f76F2fB4F5d) diff --git a/example.ts b/example.ts new file mode 100644 index 0000000..0d005fa --- /dev/null +++ b/example.ts @@ -0,0 +1,22 @@ +import { buildASTSchema, graphql } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts' +import { gql } from './mod.ts' + +const typeDefs = gql` + type Query { + hello: String + } +` + +const query = ` +{ + hello +} +` + +const resolvers = { + hello: () => 'world' +} + +const schema = buildASTSchema(typeDefs) + +console.log(await graphql(schema, query, resolvers)) diff --git a/mod.ts b/mod.ts index 0d34804..b9e64a8 100644 --- a/mod.ts +++ b/mod.ts @@ -1,42 +1,39 @@ -import { parse } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts' - -// Strip insignificant whitespace -// Note that this could do a lot more, such as reorder fields etc. -const normalize = (x: string) => x.replace(/[\s,]+/g, ' ').trim() +import { parse, DocumentNode, DefinitionNode, Location } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts' // A map docString -> graphql document -let docCache: any = {} +const docCache = new Map() // A map fragmentName -> [normalized source] -let fragmentSourceMap: any = {} +const fragmentSourceMap = new Map>() -function cacheKeyFromLoc(loc: any) { - return normalize(loc.source.body.substring(loc.start, loc.end)) +let printFragmentWarnings = true +let experimentalFragmentVariables = false + +// Strip insignificant whitespace +// Note that this could do a lot more, such as reorder fields etc. +function normalize(string: string) { + return string.replace(/[\s,]+/g, ' ').trim() } -// For testing. -export function resetCaches() { - docCache = {} - fragmentSourceMap = {} +function cacheKeyFromLoc(loc: Location) { + return normalize(loc.source.body.substring(loc.start, loc.end)) } // Take a unstripped parsed document (query/mutation or even fragment), and // check all fragment definitions, checking for name->source uniqueness. // We also want to make sure only unique fragments exist in the document. -let printFragmentWarnings = true -function processFragments(ast: any) { - const astFragmentMap: any = {} - const definitions: any[] = [] - - for (let i = 0; i < ast.definitions.length; i++) { - const fragmentDefinition = ast.definitions[i] +function processFragments(ast: DocumentNode) { + const seenKeys = new Set() + const definitions: DefinitionNode[] = [] + ast.definitions.forEach((fragmentDefinition) => { if (fragmentDefinition.kind === 'FragmentDefinition') { const fragmentName = fragmentDefinition.name.value - const sourceKey = cacheKeyFromLoc(fragmentDefinition.loc) + const sourceKey = cacheKeyFromLoc(fragmentDefinition.loc!) // We know something about this fragment - if (fragmentSourceMap.hasOwnProperty(fragmentName) && !fragmentSourceMap[fragmentName][sourceKey]) { + let sourceKeySet = fragmentSourceMap.get(fragmentName)! + if (sourceKeySet && !sourceKeySet.has(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) { @@ -48,122 +45,123 @@ function processFragments(ast: any) { '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 + } else if (!sourceKeySet) { + fragmentSourceMap.set(fragmentName, (sourceKeySet = new Set())) } - if (!astFragmentMap[sourceKey]) { - astFragmentMap[sourceKey] = true + sourceKeySet.add(sourceKey) + + if (!seenKeys.has(sourceKey)) { + seenKeys.add(sourceKey) definitions.push(fragmentDefinition) } } else { definitions.push(fragmentDefinition) } - } - - ast.definitions = definitions - return ast -} + }) -export function disableFragmentWarnings() { - printFragmentWarnings = false + return { + ...ast, + definitions + } } -function stripLoc(doc: any, removeLocAtThisLevel: any) { - let docType = Object.prototype.toString.call(doc) +function stripLoc(doc: DocumentNode) { + const workSet = new Set>(doc.definitions) - if (docType === '[object Array]') { - return doc.map(function (d: any) { - return stripLoc(d, removeLocAtThisLevel) + workSet.forEach((node) => { + if (node.loc) delete node.loc + Object.keys(node).forEach((key) => { + const value = node[key] + if (value && typeof value === 'object') { + workSet.add(value) + } }) - } - - if (docType !== '[object Object]') { - throw new Error('Unexpected input.') - } - - // We don't want to remove the root loc field so we can use it - // for fragment substitution (see below) - if (removeLocAtThisLevel && doc.loc) { - delete doc.loc - } + }) - // https://github.com/apollographql/graphql-tag/issues/40 - if (doc.loc) { - delete doc.loc.startToken - delete doc.loc.endToken + const loc = doc.loc as Record + if (loc) { + delete loc.startToken + delete loc.endToken } - const keys = Object.keys(doc) - let key - let value - let valueType - - for (key in keys) { - if (keys.hasOwnProperty(key)) { - value = doc[keys[key]] - valueType = Object.prototype.toString.call(value) + return doc +} - if (valueType === '[object Object]' || valueType === '[object Array]') { - doc[keys[key]] = stripLoc(value, true) - } +function parseDocument(source: string) { + var cacheKey = normalize(source) + if (!docCache.has(cacheKey)) { + const parsed = parse(source, { + experimentalFragmentVariables + }) + if (!parsed || parsed.kind !== 'Document') { + throw new Error('Not a valid GraphQL document.') } + docCache.set( + cacheKey, + // check that all "new" fragments inside the documents are consistent with + // existing fragments of the same name + stripLoc(processFragments(parsed)) + ) } - - return doc + return docCache.get(cacheKey)! } -let experimentalFragmentVariables = false - -function parseDocument(doc: string) { - const cacheKey = normalize(doc) - - if (docCache[cacheKey]) { - return docCache[cacheKey] +/** + * Create a GraphQL AST from template literal + * @param literals + * @param args + * + * @example + * ```ts + * import { buildASTSchema, graphql } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts' + * import { gql } from 'https://deno.land/x/graphql_tag/mod.ts' + * + * const typeDefs = gql` + * type Query { + * hello: String + * } + *` + * + * const query = `{ hello }` + * + * const resolvers = { hello: () => 'world' } + * + * console.log(await graphql(buildASTSchema(typeDefs), query, resolvers)) + * ``` + */ +export function gql(literals: string | readonly string[], ...args: any[]) { + if (typeof literals === 'string') { + literals = [literals] } - let parsed = parse(doc, { - experimentalFragmentVariables + let result = literals[0] + + args.forEach((arg, i) => { + if (arg && arg.kind === 'Document') { + result += arg.loc.source.body + } else { + result += arg + } + result += literals[i + 1] }) - 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 - parsed = processFragments(parsed) - parsed = stripLoc(parsed, false) - docCache[cacheKey] = parsed + return parseDocument(result) +} - return parsed +export function resetCaches() { + docCache.clear() + fragmentSourceMap.clear() } -export function enableExperimentalFragmentletiables() { +export function disableFragmentWarnings() { + printFragmentWarnings = false +} + +export function enableExperimentalFragmentVariables() { experimentalFragmentVariables = true } export function disableExperimentalFragmentVariables() { experimentalFragmentVariables = false } - -// XXX This should eventually disallow arbitrary string interpolation, like Relay does -export function gql(...args: any[]) { - // We always get literals[0] and then matching post literals for each arg given - const literals = args[0] - let result = typeof literals === 'string' ? literals : literals[0] - - for (let i = 1; i < args.length; i++) { - if (args[i] && args[i].kind && args[i].kind === 'Document') { - result += args[i].loc.source.body - } else { - result += args[i] - } - - result += literals[i] - } - - return parseDocument(result) -} diff --git a/mod_test.ts b/mod_test.ts new file mode 100644 index 0000000..cfd18da --- /dev/null +++ b/mod_test.ts @@ -0,0 +1,21 @@ +import { DocumentNode, buildASTSchema, isSchema } from 'https://deno.land/x/graphql_deno@v15.0.0/mod.ts' +import { describe, it, run, expect } from 'https://deno.land/x/wizard@0.1.3/mod.ts' +import { gql } from './mod.ts' + +const typeDefs = gql` + type Query { + hello: String + } +` + +it('Returns a valid document node', () => { + expect(typeDefs.kind).toBe('Document') +}) + +it('Creates a valid schema from AST', () => { + const schema = buildASTSchema(typeDefs) + + expect(isSchema(schema)).toBe(true) +}) + +run()