├── .prettierrc ├── resources ├── ts-register.js ├── utils.js ├── build.js └── gen-changelog.js ├── .eslintignore ├── .mocharc.yml ├── .prettierignore ├── .babelrc.json ├── .babelrc-npm.json ├── .nycrc.yml ├── tsconfig.json ├── .gitignore ├── .github ├── CONTRIBUTING.md └── workflows │ └── ci.yml ├── src ├── utils │ ├── __tests__ │ │ └── base64-test.ts │ └── base64.ts ├── __testUtils__ │ ├── dedent.ts │ └── __tests__ │ │ └── dedent-test.ts ├── node │ ├── plural.ts │ ├── __tests__ │ │ ├── nodeAsync-test.ts │ │ ├── plural-test.ts │ │ ├── global-test.ts │ │ └── node-test.ts │ └── node.ts ├── __tests__ │ ├── starWarsMutation-test.ts │ ├── starWarsData.ts │ ├── starWarsObjectIdentification-test.ts │ ├── starWarsConnection-test.ts │ └── starWarsSchema.ts ├── index.ts ├── mutation │ ├── mutation.ts │ └── __tests__ │ │ └── mutation-test.ts └── connection │ ├── arrayConnection.ts │ ├── connection.ts │ └── __tests__ │ ├── connection-test.ts │ └── arrayConnection-test.ts ├── CONTRIBUTING.md ├── cspell.json ├── .flowconfig ├── LICENSE ├── package.json ├── README.md └── .eslintrc.yml /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /resources/ts-register.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('@babel/register')({ extensions: ['.ts'] }); 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Copied from '.gitignore', please keep it in sync. 2 | node_modules 3 | coverage 4 | npmDist 5 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | throw-deprecation: true 2 | check-leaks: true 3 | require: 4 | - 'resources/ts-register.js' 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Copied from '.gitignore', please keep it in sync. 2 | node_modules 3 | coverage 4 | npmDist 5 | -------------------------------------------------------------------------------- /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-transform-typescript"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { "bugfixes": true, "targets": { "node": "current" } } 7 | ] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.babelrc-npm.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-transform-typescript"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { "modules": "commonjs", "targets": { "node": "12" } } 7 | ] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.nycrc.yml: -------------------------------------------------------------------------------- 1 | all: true 2 | include: 3 | - 'src/' 4 | exclude: [] 5 | clean: true 6 | temp-directory: 'coverage' 7 | report-dir: 'coverage' 8 | skip-full: true 9 | reporter: [json, html, text] 10 | check-coverage: true 11 | branches: 100 12 | lines: 100 13 | functions: 100 14 | statements: 100 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], 6 | "target": "es2019", 7 | "strict": true, 8 | "noEmit": true, 9 | "isolatedModules": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This .gitignore only ignores files specific to this repository. 2 | # If you see other files generated by your OS or tools you use, consider 3 | # creating a global .gitignore file. 4 | # 5 | # https://help.github.com/articles/ignoring-files/#create-a-global-gitignore 6 | # https://www.gitignore.io/ 7 | 8 | node_modules 9 | coverage 10 | npmDist 11 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | After cloning this repo, ensure dependencies are installed by running: 4 | 5 | ```sh 6 | npm install 7 | ``` 8 | 9 | GraphQL Relay is written in ES6 using [Babel](https://babeljs.io/), widely 10 | consumable JavaScript can be produced by running: 11 | 12 | ```sh 13 | npm run build 14 | ``` 15 | 16 | Once `npm run build` has run, you may `import` or `require()` directly from 17 | node. 18 | 19 | The full test suite can be evaluated by running: 20 | 21 | ```sh 22 | npm test 23 | ``` 24 | -------------------------------------------------------------------------------- /src/utils/__tests__/base64-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import { base64, unbase64 } from '../base64'; 5 | 6 | const exampleUtf8 = 'Some examples: ͢❤😀'; 7 | const exampleBase64 = 'U29tZSBleGFtcGxlczogIM2i4p2k8J+YgA=='; 8 | 9 | describe('base64 conversion', () => { 10 | it('converts from utf-8 to base64', () => { 11 | expect(base64(exampleUtf8)).to.equal(exampleBase64); 12 | }); 13 | 14 | it('converts from base64 to utf-8', () => { 15 | expect(unbase64(exampleBase64)).to.equal(exampleUtf8); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Publishing 2 | 3 | This package uses a custom publish flow. 4 | 5 | **IMPORTANT**: Do not push directly to `main` - every change must go through a PR 6 | otherwise changelog generation will fail. 7 | 8 | ```sh 9 | npm install 10 | npm version patch # or minor or major 11 | git push --follow-tags 12 | npm run build 13 | cd npmDist 14 | npm publish --tag=next 15 | ``` 16 | 17 | Then test it by installing `graphql-relay@next` from npm... 18 | 19 | All good? Publish: 20 | 21 | ```sh 22 | npm dist-tags add graphql-relay@VERSION_NUMBER latest 23 | ``` 24 | 25 | Finally generate the CHANGELOG: 26 | 27 | ```sh 28 | node resources/gen-changelog.js 29 | ``` 30 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "en", 3 | "ignorePaths": [ 4 | // Copied from '.gitignore', please keep it in sync. 5 | ".eslintcache", 6 | "node_modules", 7 | "coverage", 8 | "npmDist", 9 | 10 | // Excluded from spelling check 11 | "cspell.json", 12 | "package.json", 13 | "package-lock.json", 14 | "tsconfig.json" 15 | ], 16 | "words": [ 17 | "arrayconnection", 18 | "unbase", 19 | "unbased", 20 | 21 | // Different words used inside tests 22 | 23 | "corellian", 24 | "dschafer", 25 | "leebyron", 26 | "schrockn", 27 | "Ghvd", 28 | "Glvbjox", 29 | "Glvbjoy", 30 | "Gxlczog", 31 | "Nlcjox", 32 | "Nlcjoy", 33 | "Xljb", 34 | 35 | // TODO: contribute upstream 36 | "transpilation", 37 | 38 | // TODO: remove below words 39 | "QL's", // GraphQL's 40 | "QLID" // GraphQLID 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .* 3 | !/src 4 | !/node_modules/graphql 5 | 6 | [include] 7 | 8 | [declarations] 9 | /node_modules/graphql 10 | 11 | [lints] 12 | sketchy-null=error 13 | sketchy-number=error 14 | untyped-type-import=error 15 | nonstrict-import=off 16 | untyped-import=error 17 | unclear-type=off 18 | deprecated-type=error 19 | deprecated-utility=error 20 | unsafe-getters-setters=error 21 | unnecessary-optional-chain=error 22 | unnecessary-invariant=error 23 | signature-verification-failure=error 24 | implicit-inexact-object=error 25 | ambiguous-object-type=off 26 | uninitialized-instance-property=error 27 | default-import-access=error 28 | invalid-import-star-use=error 29 | non-const-var-export=error 30 | this-in-exported-function=error 31 | mixed-import-and-require=error 32 | export-renamed-default=error 33 | 34 | [options] 35 | all=true 36 | module.use_strict=true 37 | exact_by_default=true 38 | babel_loose_array_spread=true 39 | experimental.const_params=true 40 | include_warnings=true 41 | 42 | [version] 43 | ^0.159.0 44 | -------------------------------------------------------------------------------- /src/__testUtils__/dedent.ts: -------------------------------------------------------------------------------- 1 | export function dedentString(string: string): string { 2 | const trimmedStr = string 3 | .replace(/^\n*/m, '') // remove leading newline 4 | .replace(/[ \t\n]*$/, ''); // remove trailing spaces and tabs 5 | 6 | // fixes indentation by removing leading spaces and tabs from each line 7 | let indent = ''; 8 | for (const char of trimmedStr) { 9 | if (char !== ' ' && char !== '\t') { 10 | break; 11 | } 12 | indent += char; 13 | } 14 | 15 | return trimmedStr.replace(RegExp('^' + indent, 'mg'), ''); // remove indent 16 | } 17 | 18 | /** 19 | * An ES6 string tag that fixes indentation and also trims string. 20 | * 21 | * Example usage: 22 | * const str = dedent` 23 | * { 24 | * test 25 | * } 26 | * `; 27 | * str === "{\n test\n}"; 28 | */ 29 | export function dedent( 30 | strings: ReadonlyArray, 31 | ...values: ReadonlyArray 32 | ): string { 33 | let str = strings[0]; 34 | 35 | for (let i = 1; i < strings.length; ++i) { 36 | str += values[i - 1] + strings[i]; // interpolation 37 | } 38 | return dedentString(str); 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) GraphQL Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/node/plural.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLList, GraphQLNonNull, getNullableType } from 'graphql'; 2 | 3 | import type { 4 | GraphQLFieldConfig, 5 | GraphQLInputType, 6 | GraphQLOutputType, 7 | GraphQLResolveInfo, 8 | } from 'graphql'; 9 | 10 | interface PluralIdentifyingRootFieldConfig { 11 | argName: string; 12 | inputType: GraphQLInputType; 13 | outputType: GraphQLOutputType; 14 | resolveSingleInput: ( 15 | input: any, 16 | context: any, 17 | info: GraphQLResolveInfo, 18 | ) => unknown; 19 | description?: string; 20 | } 21 | 22 | export function pluralIdentifyingRootField( 23 | config: PluralIdentifyingRootFieldConfig, 24 | ): GraphQLFieldConfig { 25 | return { 26 | description: config.description, 27 | type: new GraphQLList(config.outputType), 28 | args: { 29 | [config.argName]: { 30 | type: new GraphQLNonNull( 31 | new GraphQLList( 32 | new GraphQLNonNull(getNullableType(config.inputType)), 33 | ), 34 | ), 35 | }, 36 | }, 37 | resolve(_obj, args, context, info) { 38 | const inputs = args[config.argName]; 39 | return inputs.map((input: unknown) => 40 | config.resolveSingleInput(input, context, info), 41 | ); 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/__tests__/starWarsMutation-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | import { graphqlSync } from 'graphql'; 4 | 5 | import { StarWarsSchema as schema } from './starWarsSchema'; 6 | 7 | describe('Star Wars mutations', () => { 8 | it('mutates the data set', () => { 9 | const source = ` 10 | mutation ($input: IntroduceShipInput!) { 11 | introduceShip(input: $input) { 12 | ship { 13 | id 14 | name 15 | } 16 | faction { 17 | name 18 | } 19 | clientMutationId 20 | } 21 | } 22 | `; 23 | const variableValues = { 24 | input: { 25 | shipName: 'B-Wing', 26 | factionId: '1', 27 | clientMutationId: 'abcde', 28 | }, 29 | }; 30 | 31 | const result = graphqlSync({ schema, source, variableValues }); 32 | expect(result).to.deep.equal({ 33 | data: { 34 | introduceShip: { 35 | ship: { 36 | id: 'U2hpcDo5', 37 | name: 'B-Wing', 38 | }, 39 | faction: { 40 | name: 'Alliance to Restore the Republic', 41 | }, 42 | clientMutationId: 'abcde', 43 | }, 44 | }, 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Types for creating connection types in the schema 2 | export type { 3 | Connection, 4 | ConnectionArguments, 5 | ConnectionCursor, 6 | ConnectionConfig, 7 | GraphQLConnectionDefinitions, 8 | Edge, 9 | PageInfo, 10 | } from './connection/connection'; 11 | 12 | // Helpers for creating connection types in the schema 13 | export { 14 | backwardConnectionArgs, 15 | connectionArgs, 16 | connectionDefinitions, 17 | forwardConnectionArgs, 18 | } from './connection/connection'; 19 | 20 | // Helpers for creating connections from arrays 21 | export { 22 | connectionFromArray, 23 | connectionFromArraySlice, 24 | connectionFromPromisedArray, 25 | connectionFromPromisedArraySlice, 26 | cursorForObjectInConnection, 27 | cursorToOffset, 28 | getOffsetWithDefault, 29 | offsetToCursor, 30 | } from './connection/arrayConnection'; 31 | 32 | // Helper for creating mutations with client mutation IDs 33 | export { mutationWithClientMutationId } from './mutation/mutation'; 34 | 35 | // Helper for creating node definitions 36 | export { nodeDefinitions } from './node/node'; 37 | 38 | // Helper for creating plural identifying root fields 39 | export { pluralIdentifyingRootField } from './node/plural'; 40 | 41 | // Utilities for creating global IDs in systems that don't have them. 42 | export { fromGlobalId, globalIdField, toGlobalId } from './node/node'; 43 | -------------------------------------------------------------------------------- /src/node/__tests__/nodeAsync-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import { 5 | GraphQLID, 6 | GraphQLNonNull, 7 | GraphQLObjectType, 8 | GraphQLSchema, 9 | GraphQLString, 10 | graphql, 11 | } from 'graphql'; 12 | 13 | import { nodeDefinitions } from '../node'; 14 | 15 | const userData = [ 16 | { 17 | id: '1', 18 | name: 'John Doe', 19 | }, 20 | { 21 | id: '2', 22 | name: 'Jane Smith', 23 | }, 24 | ]; 25 | 26 | const { nodeField, nodeInterface } = nodeDefinitions( 27 | (id) => userData.find((obj) => obj.id === id), 28 | () => userType.name, 29 | ); 30 | 31 | const userType: GraphQLObjectType = new GraphQLObjectType({ 32 | name: 'User', 33 | interfaces: [nodeInterface], 34 | fields: () => ({ 35 | id: { 36 | type: new GraphQLNonNull(GraphQLID), 37 | }, 38 | name: { 39 | type: GraphQLString, 40 | }, 41 | }), 42 | }); 43 | 44 | const queryType = new GraphQLObjectType({ 45 | name: 'Query', 46 | fields: () => ({ 47 | node: nodeField, 48 | }), 49 | }); 50 | 51 | const schema = new GraphQLSchema({ 52 | query: queryType, 53 | types: [userType], 54 | }); 55 | 56 | describe('Node interface and fields with async object fetcher', () => { 57 | it('gets the correct ID for users', async () => { 58 | const source = ` 59 | { 60 | node(id: "1") { 61 | id 62 | } 63 | } 64 | `; 65 | 66 | expect(await graphql({ schema, source })).to.deep.equal({ 67 | data: { 68 | node: { id: '1' }, 69 | }, 70 | }); 71 | }); 72 | 73 | it('gets the correct name for users', async () => { 74 | const source = ` 75 | { 76 | node(id: "1") { 77 | id 78 | ... on User { 79 | name 80 | } 81 | } 82 | } 83 | `; 84 | 85 | expect(await graphql({ schema, source })).to.deep.equal({ 86 | data: { 87 | node: { id: '1', name: 'John Doe' }, 88 | }, 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/__tests__/starWarsData.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This defines a basic set of data for our Star Wars Schema. 3 | * 4 | * This data is hard coded for the sake of the demo, but you could imagine 5 | * fetching this data from a backend service rather than from hardcoded 6 | * JSON objects in a more complex demo. 7 | */ 8 | 9 | interface Ship { 10 | id: string; 11 | name: string; 12 | } 13 | 14 | const allShips: Array = [ 15 | { id: '1', name: 'X-Wing' }, 16 | { id: '2', name: 'Y-Wing' }, 17 | { id: '3', name: 'A-Wing' }, 18 | 19 | // Yeah, technically it's Corellian. But it flew in the service of the rebels, 20 | // so for the purposes of this demo it's a rebel ship. 21 | { id: '4', name: 'Millennium Falcon' }, 22 | { id: '5', name: 'Home One' }, 23 | { id: '6', name: 'TIE Fighter' }, 24 | { id: '7', name: 'TIE Interceptor' }, 25 | { id: '8', name: 'Executor' }, 26 | ]; 27 | 28 | interface Faction { 29 | id: string; 30 | name: string; 31 | ships: Array; 32 | } 33 | 34 | const rebels: Faction = { 35 | id: '1', 36 | name: 'Alliance to Restore the Republic', 37 | ships: ['1', '2', '3', '4', '5'], 38 | }; 39 | 40 | const empire: Faction = { 41 | id: '2', 42 | name: 'Galactic Empire', 43 | ships: ['6', '7', '8'], 44 | }; 45 | 46 | const allFactions: Array = [rebels, empire]; 47 | 48 | let nextShip = 9; 49 | export function createShip(shipName: string, factionId: string): Ship { 50 | const newShip = { 51 | id: String(nextShip++), 52 | name: shipName, 53 | }; 54 | 55 | allShips.push(newShip); 56 | 57 | const faction = allFactions.find((obj) => obj.id === factionId); 58 | faction?.ships.push(newShip.id); 59 | return newShip; 60 | } 61 | 62 | export function getShip(id: string): Ship | undefined { 63 | return allShips.find((ship) => ship.id === id); 64 | } 65 | 66 | export function getFaction(id: string): Faction | undefined { 67 | return allFactions.find((faction) => faction.id === id); 68 | } 69 | 70 | export function getRebels(): Faction { 71 | return rebels; 72 | } 73 | 74 | export function getEmpire(): Faction { 75 | return empire; 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-relay", 3 | "version": "0.10.2", 4 | "description": "A library to help construct a graphql-js server supporting react-relay.", 5 | "license": "MIT", 6 | "private": true, 7 | "main": "index", 8 | "typesVersions": { 9 | ">=4.1.0": { 10 | "*": [ 11 | "*" 12 | ] 13 | } 14 | }, 15 | "sideEffects": false, 16 | "homepage": "https://github.com/graphql/graphql-relay-js", 17 | "bugs": { 18 | "url": "https://github.com/graphql/graphql-relay-js/issues" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/graphql/graphql-relay-js.git" 23 | }, 24 | "keywords": [ 25 | "graphql" 26 | ], 27 | "engines": { 28 | "node": "^12.20.0 || ^14.15.0 || >= 15.9.0" 29 | }, 30 | "scripts": { 31 | "preversion": "npm ci && npm run test", 32 | "test": "npm run lint && npm run check && npm run testonly && npm run prettier:check && npm run check:spelling", 33 | "lint": "eslint --max-warnings 0 .", 34 | "check": "tsc --pretty", 35 | "testonly": "mocha --full-trace src/**/__tests__/**/*-test.ts", 36 | "testonly:cover": "nyc npm run testonly", 37 | "prettier": "prettier --write --list-different .", 38 | "prettier:check": "prettier --check .", 39 | "check:spelling": "cspell --no-progress '**/*'", 40 | "build": "node resources/build.js" 41 | }, 42 | "peerDependencies": { 43 | "graphql": "^16.2.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.15.5", 47 | "@babel/eslint-parser": "^7.15.4", 48 | "@babel/node": "^7.15.4", 49 | "@babel/plugin-transform-typescript": "^7.14.6", 50 | "@babel/preset-env": "^7.15.6", 51 | "@babel/register": "^7.15.3", 52 | "@types/chai": "^4.2.19", 53 | "@types/mocha": "^8.2.2", 54 | "@types/node": "^15.12.5", 55 | "@typescript-eslint/eslint-plugin": "^4.31.0", 56 | "@typescript-eslint/parser": "^4.31.0", 57 | "chai": "^4.3.4", 58 | "cspell": "^5.9.0", 59 | "eslint": "^7.32.0", 60 | "eslint-plugin-import": "^2.24.2", 61 | "eslint-plugin-istanbul": "^0.1.2", 62 | "eslint-plugin-node": "^11.1.0", 63 | "flow-bin": "^0.159.0", 64 | "graphql": "^16.2.0", 65 | "mocha": "^9.1.1", 66 | "nyc": "^15.1.0", 67 | "prettier": "^2.4.0", 68 | "typescript": "^4.4.2" 69 | }, 70 | "publishConfig": { 71 | "tag": "latest" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/node/__tests__/plural-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import { 5 | GraphQLObjectType, 6 | GraphQLSchema, 7 | GraphQLString, 8 | graphqlSync, 9 | printSchema, 10 | } from 'graphql'; 11 | 12 | import { dedent } from '../../__testUtils__/dedent'; 13 | 14 | import { pluralIdentifyingRootField } from '../plural'; 15 | 16 | const userType = new GraphQLObjectType({ 17 | name: 'User', 18 | fields: () => ({ 19 | username: { 20 | type: GraphQLString, 21 | }, 22 | url: { 23 | type: GraphQLString, 24 | }, 25 | }), 26 | }); 27 | 28 | const queryType = new GraphQLObjectType({ 29 | name: 'Query', 30 | fields: () => ({ 31 | usernames: pluralIdentifyingRootField({ 32 | argName: 'usernames', 33 | description: 'Map from a username to the user', 34 | inputType: GraphQLString, 35 | outputType: userType, 36 | resolveSingleInput: (username: string, context: { lang: string }) => ({ 37 | username, 38 | url: `www.facebook.com/${username}?lang=${context.lang}`, 39 | }), 40 | }), 41 | }), 42 | }); 43 | 44 | const schema = new GraphQLSchema({ 45 | query: queryType, 46 | }); 47 | 48 | describe('pluralIdentifyingRootField()', () => { 49 | it('allows fetching', () => { 50 | const source = ` 51 | { 52 | usernames(usernames:[ "dschafer", "leebyron", "schrockn" ]) { 53 | username 54 | url 55 | } 56 | } 57 | `; 58 | 59 | const contextValue = { lang: 'en' }; 60 | expect(graphqlSync({ schema, source, contextValue })).to.deep.equal({ 61 | data: { 62 | usernames: [ 63 | { 64 | username: 'dschafer', 65 | url: 'www.facebook.com/dschafer?lang=en', 66 | }, 67 | { 68 | username: 'leebyron', 69 | url: 'www.facebook.com/leebyron?lang=en', 70 | }, 71 | { 72 | username: 'schrockn', 73 | url: 'www.facebook.com/schrockn?lang=en', 74 | }, 75 | ], 76 | }, 77 | }); 78 | }); 79 | 80 | it('generates correct types', () => { 81 | expect(printSchema(schema)).to.deep.equal(dedent` 82 | type Query { 83 | """Map from a username to the user""" 84 | usernames(usernames: [String!]!): [User] 85 | } 86 | 87 | type User { 88 | username: String 89 | url: String 90 | } 91 | `); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/__tests__/starWarsObjectIdentification-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | import { graphqlSync } from 'graphql'; 4 | 5 | import { StarWarsSchema as schema } from './starWarsSchema'; 6 | 7 | describe('Star Wars object identification', () => { 8 | it('fetches the ID and name of the rebels', () => { 9 | const source = ` 10 | { 11 | rebels { 12 | id 13 | name 14 | } 15 | } 16 | `; 17 | 18 | expect(graphqlSync({ schema, source })).to.deep.equal({ 19 | data: { 20 | rebels: { 21 | id: 'RmFjdGlvbjox', 22 | name: 'Alliance to Restore the Republic', 23 | }, 24 | }, 25 | }); 26 | }); 27 | 28 | it('fetches the rebels by global ID', () => { 29 | const source = ` 30 | { 31 | node(id: "RmFjdGlvbjox") { 32 | id 33 | ... on Faction { 34 | name 35 | } 36 | } 37 | } 38 | `; 39 | 40 | expect(graphqlSync({ schema, source })).to.deep.equal({ 41 | data: { 42 | node: { 43 | id: 'RmFjdGlvbjox', 44 | name: 'Alliance to Restore the Republic', 45 | }, 46 | }, 47 | }); 48 | }); 49 | 50 | it('fetches the ID and name of the empire', () => { 51 | const source = ` 52 | { 53 | empire { 54 | id 55 | name 56 | } 57 | } 58 | `; 59 | 60 | expect(graphqlSync({ schema, source })).to.deep.equal({ 61 | data: { 62 | empire: { id: 'RmFjdGlvbjoy', name: 'Galactic Empire' }, 63 | }, 64 | }); 65 | }); 66 | 67 | it('fetches the empire by global ID', () => { 68 | const source = ` 69 | { 70 | node(id: "RmFjdGlvbjoy") { 71 | id 72 | ... on Faction { 73 | name 74 | } 75 | } 76 | } 77 | `; 78 | 79 | expect(graphqlSync({ schema, source })).to.deep.equal({ 80 | data: { 81 | node: { id: 'RmFjdGlvbjoy', name: 'Galactic Empire' }, 82 | }, 83 | }); 84 | }); 85 | 86 | it('fetches the X-Wing by global ID', () => { 87 | const source = ` 88 | { 89 | node(id: "U2hpcDox") { 90 | id 91 | ... on Ship { 92 | name 93 | } 94 | } 95 | } 96 | `; 97 | 98 | expect(graphqlSync({ schema, source })).to.deep.equal({ 99 | data: { 100 | node: { id: 'U2hpcDox', name: 'X-Wing' }, 101 | }, 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /resources/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const childProcess = require('child_process'); 6 | 7 | const prettier = require('prettier'); 8 | 9 | function exec(command, options) { 10 | const output = childProcess.execSync(command, { 11 | maxBuffer: 10 * 1024 * 1024, // 10MB 12 | encoding: 'utf-8', 13 | ...options, 14 | }); 15 | return output && output.trimEnd(); 16 | } 17 | 18 | function readdirRecursive(dirPath, opts = {}) { 19 | const { ignoreDir } = opts; 20 | const result = []; 21 | for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { 22 | const name = dirent.name; 23 | if (!dirent.isDirectory()) { 24 | result.push(dirent.name); 25 | continue; 26 | } 27 | 28 | if (ignoreDir && ignoreDir.test(name)) { 29 | continue; 30 | } 31 | const list = readdirRecursive(path.join(dirPath, name), opts).map((f) => 32 | path.join(name, f), 33 | ); 34 | result.push(...list); 35 | } 36 | return result; 37 | } 38 | 39 | function showDirStats(dirPath) { 40 | const fileTypes = {}; 41 | let totalSize = 0; 42 | 43 | for (const filepath of readdirRecursive(dirPath)) { 44 | const name = filepath.split(path.sep).pop(); 45 | const [base, ...splitExt] = name.split('.'); 46 | const ext = splitExt.join('.'); 47 | 48 | const filetype = ext ? '*.' + ext : base; 49 | fileTypes[filetype] = fileTypes[filetype] || { filepaths: [], size: 0 }; 50 | 51 | const { size } = fs.lstatSync(path.join(dirPath, filepath)); 52 | totalSize += size; 53 | fileTypes[filetype].size += size; 54 | fileTypes[filetype].filepaths.push(filepath); 55 | } 56 | 57 | let stats = []; 58 | for (const [filetype, typeStats] of Object.entries(fileTypes)) { 59 | const numFiles = typeStats.filepaths.length; 60 | 61 | if (numFiles > 1) { 62 | stats.push([filetype + ' x' + numFiles, typeStats.size]); 63 | } else { 64 | stats.push([typeStats.filepaths[0], typeStats.size]); 65 | } 66 | } 67 | stats.sort((a, b) => b[1] - a[1]); 68 | stats = stats.map(([type, size]) => [type, (size / 1024).toFixed(2) + ' KB']); 69 | 70 | const typeMaxLength = Math.max(...stats.map((x) => x[0].length)); 71 | const sizeMaxLength = Math.max(...stats.map((x) => x[1].length)); 72 | for (const [type, size] of stats) { 73 | console.log( 74 | type.padStart(typeMaxLength) + ' | ' + size.padStart(sizeMaxLength), 75 | ); 76 | } 77 | 78 | console.log('-'.repeat(typeMaxLength + 3 + sizeMaxLength)); 79 | const totalMB = (totalSize / 1024 / 1024).toFixed(2) + ' MB'; 80 | console.log( 81 | 'Total'.padStart(typeMaxLength) + ' | ' + totalMB.padStart(sizeMaxLength), 82 | ); 83 | } 84 | 85 | const prettierConfig = JSON.parse( 86 | fs.readFileSync(require.resolve('../.prettierrc'), 'utf-8'), 87 | ); 88 | 89 | function writeGeneratedFile(filepath, body) { 90 | const formatted = prettier.format(body, { filepath, ...prettierConfig }); 91 | fs.writeFileSync(filepath, formatted); 92 | } 93 | 94 | module.exports = { 95 | exec, 96 | readdirRecursive, 97 | showDirStats, 98 | writeGeneratedFile, 99 | }; 100 | -------------------------------------------------------------------------------- /src/__testUtils__/__tests__/dedent-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | 4 | import { dedent, dedentString } from '../dedent'; 5 | 6 | describe('dedentString', () => { 7 | it('removes indentation in typical usage', () => { 8 | const output = dedentString(` 9 | type Query { 10 | me: User 11 | } 12 | 13 | type User { 14 | id: ID 15 | name: String 16 | } 17 | `); 18 | expect(output).to.equal( 19 | [ 20 | 'type Query {', 21 | ' me: User', 22 | '}', 23 | '', 24 | 'type User {', 25 | ' id: ID', 26 | ' name: String', 27 | '}', 28 | ].join('\n'), 29 | ); 30 | }); 31 | 32 | it('removes only the first level of indentation', () => { 33 | const output = dedentString(` 34 | first 35 | second 36 | third 37 | fourth 38 | `); 39 | expect(output).to.equal( 40 | ['first', ' second', ' third', ' fourth'].join('\n'), 41 | ); 42 | }); 43 | 44 | it('does not escape special characters', () => { 45 | const output = dedentString(` 46 | type Root { 47 | field(arg: String = "wi\th de\fault"): String 48 | } 49 | `); 50 | expect(output).to.equal( 51 | [ 52 | 'type Root {', 53 | ' field(arg: String = "wi\th de\fault"): String', 54 | '}', 55 | ].join('\n'), 56 | ); 57 | }); 58 | 59 | it('also removes indentation using tabs', () => { 60 | const output = dedentString(` 61 | \t\t type Query { 62 | \t\t me: User 63 | \t\t } 64 | `); 65 | expect(output).to.equal(['type Query {', ' me: User', '}'].join('\n')); 66 | }); 67 | 68 | it('removes leading and trailing newlines', () => { 69 | const output = dedentString(` 70 | 71 | 72 | type Query { 73 | me: User 74 | } 75 | 76 | 77 | `); 78 | expect(output).to.equal(['type Query {', ' me: User', '}'].join('\n')); 79 | }); 80 | 81 | it('removes all trailing spaces and tabs', () => { 82 | const output = dedentString(` 83 | type Query { 84 | me: User 85 | } 86 | \t\t \t `); 87 | expect(output).to.equal(['type Query {', ' me: User', '}'].join('\n')); 88 | }); 89 | 90 | it('works on text without leading newline', () => { 91 | const output = dedentString(` type Query { 92 | me: User 93 | } 94 | `); 95 | expect(output).to.equal(['type Query {', ' me: User', '}'].join('\n')); 96 | }); 97 | }); 98 | 99 | describe('dedent', () => { 100 | it('removes indentation in typical usage', () => { 101 | const output = dedent` 102 | type Query { 103 | me: User 104 | } 105 | `; 106 | expect(output).to.equal(['type Query {', ' me: User', '}'].join('\n')); 107 | }); 108 | 109 | it('supports expression interpolation', () => { 110 | const name = 'John'; 111 | const surname = 'Doe'; 112 | const output = dedent` 113 | { 114 | "me": { 115 | "name": "${name}", 116 | "surname": "${surname}" 117 | } 118 | } 119 | `; 120 | expect(output).to.equal( 121 | [ 122 | '{', 123 | ' "me": {', 124 | ' "name": "John",', 125 | ' "surname": "Doe"', 126 | ' }', 127 | '}', 128 | ].join('\n'), 129 | ); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/mutation/mutation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInputObjectType, 3 | GraphQLNonNull, 4 | GraphQLObjectType, 5 | GraphQLString, 6 | resolveObjMapThunk, 7 | } from 'graphql'; 8 | 9 | import type { 10 | GraphQLFieldConfig, 11 | GraphQLFieldExtensions, 12 | GraphQLInputFieldConfig, 13 | GraphQLResolveInfo, 14 | ThunkObjMap, 15 | } from 'graphql'; 16 | 17 | type MutationFn = ( 18 | object: TInput, 19 | ctx: TContext, 20 | info: GraphQLResolveInfo, 21 | ) => TOutput; 22 | 23 | /** 24 | * A description of a mutation consumable by mutationWithClientMutationId 25 | * to create a GraphQLFieldConfig for that mutation. 26 | * 27 | * The inputFields and outputFields should not include `clientMutationId`, 28 | * as this will be provided automatically. 29 | * 30 | * An input object will be created containing the input fields, and an 31 | * object will be created containing the output fields. 32 | * 33 | * mutateAndGetPayload will receive an Object with a key for each 34 | * input field, and it should return an Object with a key for each 35 | * output field. It may return synchronously, or return a Promise. 36 | */ 37 | interface MutationConfig { 38 | name: string; 39 | description?: string; 40 | deprecationReason?: string; 41 | extensions?: GraphQLFieldExtensions; 42 | inputFields: ThunkObjMap; 43 | outputFields: ThunkObjMap>; 44 | mutateAndGetPayload: MutationFn | TOutput, TContext>; 45 | } 46 | 47 | /** 48 | * Returns a GraphQLFieldConfig for the mutation described by the 49 | * provided MutationConfig. 50 | */ 51 | export function mutationWithClientMutationId< 52 | TInput = any, 53 | TOutput = unknown, 54 | TContext = any, 55 | >( 56 | config: MutationConfig, 57 | ): GraphQLFieldConfig { 58 | const { name, inputFields, outputFields, mutateAndGetPayload } = config; 59 | const augmentedInputFields = () => ({ 60 | ...resolveObjMapThunk(inputFields), 61 | clientMutationId: { 62 | type: GraphQLString, 63 | }, 64 | }); 65 | const augmentedOutputFields = () => ({ 66 | ...resolveObjMapThunk(outputFields), 67 | clientMutationId: { 68 | type: GraphQLString, 69 | }, 70 | }); 71 | 72 | const outputType = new GraphQLObjectType({ 73 | name: name + 'Payload', 74 | fields: augmentedOutputFields, 75 | }); 76 | 77 | const inputType = new GraphQLInputObjectType({ 78 | name: name + 'Input', 79 | fields: augmentedInputFields, 80 | }); 81 | 82 | return { 83 | type: outputType, 84 | description: config.description, 85 | deprecationReason: config.deprecationReason, 86 | extensions: config.extensions, 87 | args: { 88 | input: { type: new GraphQLNonNull(inputType) }, 89 | }, 90 | resolve: (_, { input }, context, info) => { 91 | const { clientMutationId } = input; 92 | const payload = mutateAndGetPayload(input, context, info); 93 | if (isPromiseLike(payload)) { 94 | return payload.then(injectClientMutationId); 95 | } 96 | return injectClientMutationId(payload); 97 | 98 | function injectClientMutationId(data: unknown) { 99 | if (typeof data === 'object' && data !== null) { 100 | // @ts-expect-error FIXME It's bad idea to mutate data but we need to pass clientMutationId somehow. Maybe in future we figure out better solution satisfying all our test cases. 101 | data.clientMutationId = clientMutationId; 102 | } 103 | 104 | return data; 105 | } 106 | }, 107 | }; 108 | } 109 | 110 | // FIXME: Temporary until graphql-js resolves this issue 111 | // See, https://github.com/graphql/graphql-js/pull/3243#issuecomment-919510590 112 | function isPromiseLike(value: any): value is Promise { 113 | return typeof value?.then === 'function'; 114 | } 115 | -------------------------------------------------------------------------------- /src/node/node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInterfaceType, 3 | GraphQLList, 4 | GraphQLNonNull, 5 | GraphQLID, 6 | } from 'graphql'; 7 | 8 | import type { 9 | GraphQLFieldConfig, 10 | GraphQLResolveInfo, 11 | GraphQLTypeResolver, 12 | } from 'graphql'; 13 | 14 | import { base64, unbase64 } from '../utils/base64'; 15 | 16 | interface GraphQLNodeDefinitions { 17 | nodeInterface: GraphQLInterfaceType; 18 | nodeField: GraphQLFieldConfig; 19 | nodesField: GraphQLFieldConfig; 20 | } 21 | 22 | /** 23 | * Given a function to map from an ID to an underlying object, and a function 24 | * to map from an underlying object to the concrete GraphQLObjectType it 25 | * corresponds to, constructs a `Node` interface that objects can implement, 26 | * and a field config for a `node` root field. 27 | * 28 | * If the typeResolver is omitted, object resolution on the interface will be 29 | * handled with the `isTypeOf` method on object types, as with any GraphQL 30 | * interface without a provided `resolveType` method. 31 | */ 32 | export function nodeDefinitions( 33 | fetchById: ( 34 | id: string, 35 | context: TContext, 36 | info: GraphQLResolveInfo, 37 | ) => unknown, 38 | typeResolver?: GraphQLTypeResolver, 39 | ): GraphQLNodeDefinitions { 40 | const nodeInterface = new GraphQLInterfaceType({ 41 | name: 'Node', 42 | description: 'An object with an ID', 43 | fields: () => ({ 44 | id: { 45 | type: new GraphQLNonNull(GraphQLID), 46 | description: 'The id of the object.', 47 | }, 48 | }), 49 | resolveType: typeResolver, 50 | }); 51 | 52 | const nodeField: GraphQLFieldConfig = { 53 | description: 'Fetches an object given its ID', 54 | type: nodeInterface, 55 | args: { 56 | id: { 57 | type: new GraphQLNonNull(GraphQLID), 58 | description: 'The ID of an object', 59 | }, 60 | }, 61 | resolve: (_obj, { id }, context, info) => fetchById(id, context, info), 62 | }; 63 | 64 | const nodesField: GraphQLFieldConfig = { 65 | description: 'Fetches objects given their IDs', 66 | type: new GraphQLNonNull(new GraphQLList(nodeInterface)), 67 | args: { 68 | ids: { 69 | type: new GraphQLNonNull( 70 | new GraphQLList(new GraphQLNonNull(GraphQLID)), 71 | ), 72 | description: 'The IDs of objects', 73 | }, 74 | }, 75 | resolve: (_obj, { ids }, context, info) => 76 | ids.map((id: string) => fetchById(id, context, info)), 77 | }; 78 | 79 | return { nodeInterface, nodeField, nodesField }; 80 | } 81 | 82 | interface ResolvedGlobalId { 83 | type: string; 84 | id: string; 85 | } 86 | 87 | /** 88 | * Takes a type name and an ID specific to that type name, and returns a 89 | * "global ID" that is unique among all types. 90 | */ 91 | export function toGlobalId(type: string, id: string | number): string { 92 | return base64([type, GraphQLID.serialize(id)].join(':')); 93 | } 94 | 95 | /** 96 | * Takes the "global ID" created by toGlobalID, and returns the type name and ID 97 | * used to create it. 98 | */ 99 | export function fromGlobalId(globalId: string): ResolvedGlobalId { 100 | const unbasedGlobalId = unbase64(globalId); 101 | const delimiterPos = unbasedGlobalId.indexOf(':'); 102 | return { 103 | type: unbasedGlobalId.substring(0, delimiterPos), 104 | id: unbasedGlobalId.substring(delimiterPos + 1), 105 | }; 106 | } 107 | 108 | /** 109 | * Creates the configuration for an id field on a node, using `toGlobalId` to 110 | * construct the ID from the provided typename. The type-specific ID is fetched 111 | * by calling idFetcher on the object, or if not provided, by accessing the `id` 112 | * property on the object. 113 | */ 114 | export function globalIdField( 115 | typeName?: string, 116 | idFetcher?: ( 117 | obj: any, 118 | context: TContext, 119 | info: GraphQLResolveInfo, 120 | ) => string | number, 121 | ): GraphQLFieldConfig { 122 | return { 123 | description: 'The ID of an object', 124 | type: new GraphQLNonNull(GraphQLID), 125 | resolve: (obj, _args, context, info) => 126 | toGlobalId( 127 | typeName ?? info.parentType.name, 128 | idFetcher ? idFetcher(obj, context, info) : obj.id, 129 | ), 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /src/node/__tests__/global-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import { 5 | GraphQLInt, 6 | GraphQLList, 7 | GraphQLObjectType, 8 | GraphQLSchema, 9 | GraphQLString, 10 | graphqlSync, 11 | } from 'graphql'; 12 | 13 | import { fromGlobalId, globalIdField, nodeDefinitions } from '../node'; 14 | 15 | const userData = [ 16 | { 17 | id: '1', 18 | name: 'John Doe', 19 | }, 20 | { 21 | id: '2', 22 | name: 'Jane Smith', 23 | }, 24 | ]; 25 | 26 | const photoData = [ 27 | { 28 | photoId: '1', 29 | width: 300, 30 | }, 31 | { 32 | photoId: '2', 33 | width: 400, 34 | }, 35 | ]; 36 | 37 | const postData = [ 38 | { 39 | id: '1', 40 | text: 'lorem', 41 | }, 42 | { 43 | id: '2', 44 | text: 'ipsum', 45 | }, 46 | ]; 47 | 48 | const { nodeField, nodeInterface } = nodeDefinitions( 49 | (globalId) => { 50 | const { type, id } = fromGlobalId(globalId); 51 | switch (type) { 52 | case 'User': 53 | return userData.find((obj) => obj.id === id); 54 | case 'Photo': 55 | return photoData.find((obj) => obj.photoId === id); 56 | case 'Post': 57 | return postData.find((obj) => obj.id === id); 58 | } 59 | }, 60 | (obj) => { 61 | if (obj.name) { 62 | return userType.name; 63 | } 64 | if (obj.photoId) { 65 | return photoType.name; 66 | } 67 | 68 | // istanbul ignore else (Can't be reached) 69 | if (obj.text) { 70 | return postType.name; 71 | } 72 | }, 73 | ); 74 | 75 | const userType: GraphQLObjectType = new GraphQLObjectType({ 76 | name: 'User', 77 | interfaces: [nodeInterface], 78 | fields: () => ({ 79 | id: globalIdField('User'), 80 | name: { 81 | type: GraphQLString, 82 | }, 83 | }), 84 | }); 85 | 86 | const photoType: GraphQLObjectType = new GraphQLObjectType({ 87 | name: 'Photo', 88 | interfaces: [nodeInterface], 89 | fields: () => ({ 90 | id: globalIdField('Photo', (obj) => obj.photoId), 91 | width: { 92 | type: GraphQLInt, 93 | }, 94 | }), 95 | }); 96 | 97 | const postType: GraphQLObjectType = new GraphQLObjectType({ 98 | name: 'Post', 99 | interfaces: [nodeInterface], 100 | fields: () => ({ 101 | id: globalIdField(), 102 | text: { 103 | type: GraphQLString, 104 | }, 105 | }), 106 | }); 107 | 108 | const queryType = new GraphQLObjectType({ 109 | name: 'Query', 110 | fields: () => ({ 111 | node: nodeField, 112 | allObjects: { 113 | type: new GraphQLList(nodeInterface), 114 | resolve: () => [...userData, ...photoData, ...postData], 115 | }, 116 | }), 117 | }); 118 | 119 | const schema = new GraphQLSchema({ 120 | query: queryType, 121 | types: [userType, photoType, postType], 122 | }); 123 | 124 | describe('global ID fields', () => { 125 | it('gives different IDs', () => { 126 | const source = ` 127 | { 128 | allObjects { 129 | id 130 | } 131 | } 132 | `; 133 | 134 | expect(graphqlSync({ schema, source })).to.deep.equal({ 135 | data: { 136 | allObjects: [ 137 | { id: 'VXNlcjox' }, 138 | { id: 'VXNlcjoy' }, 139 | { id: 'UGhvdG86MQ==' }, 140 | { id: 'UGhvdG86Mg==' }, 141 | { id: 'UG9zdDox' }, 142 | { id: 'UG9zdDoy' }, 143 | ], 144 | }, 145 | }); 146 | }); 147 | 148 | it('allows to refetch the IDs', () => { 149 | const source = ` 150 | { 151 | user: node(id: "VXNlcjox") { 152 | id 153 | ... on User { 154 | name 155 | } 156 | }, 157 | photo: node(id: "UGhvdG86MQ==") { 158 | id 159 | ... on Photo { 160 | width 161 | } 162 | }, 163 | post: node(id: "UG9zdDox") { 164 | id 165 | ... on Post { 166 | text 167 | } 168 | } 169 | } 170 | `; 171 | 172 | expect(graphqlSync({ schema, source })).to.deep.equal({ 173 | data: { 174 | user: { 175 | id: 'VXNlcjox', 176 | name: 'John Doe', 177 | }, 178 | photo: { 179 | id: 'UGhvdG86MQ==', 180 | width: 300, 181 | }, 182 | post: { 183 | id: 'UG9zdDox', 184 | text: 'lorem', 185 | }, 186 | }, 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | env: 4 | NODE_VERSION_USED_FOR_DEVELOPMENT: 16 5 | jobs: 6 | lint: 7 | name: Lint source files 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ env.NODE_VERSION_USED_FOR_DEVELOPMENT }} 17 | 18 | - name: Cache Node.js modules 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/.npm 22 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.OS }}-node- 25 | 26 | - name: Install Dependencies 27 | run: npm ci 28 | 29 | - name: Lint ESLint 30 | run: npm run lint 31 | 32 | - name: Check TypeScript 33 | run: npm run check 34 | 35 | - name: Lint Prettier 36 | run: npm run prettier:check 37 | 38 | - name: Spellcheck 39 | run: npm run check:spelling 40 | 41 | checkForCommonlyIgnoredFiles: 42 | name: Check for commonly ignored files 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout repo 46 | uses: actions/checkout@v2 47 | 48 | - name: Check if commit contains files that should be ignored 49 | run: | 50 | git clone --depth 1 https://github.com/github/gitignore.git && 51 | cat gitignore/Node.gitignore $(find gitignore/Global -name "*.gitignore" | grep -v ModelSim) > all.gitignore && 52 | if [[ "$(git ls-files -iX all.gitignore)" != "" ]]; then 53 | echo "::error::Please remove these files:" 54 | git ls-files -iX all.gitignore 55 | exit 1 56 | fi 57 | 58 | checkPackageLock: 59 | name: Check health of package-lock.json file 60 | runs-on: ubuntu-latest 61 | steps: 62 | - name: Checkout repo 63 | uses: actions/checkout@v2 64 | 65 | - name: Setup Node.js 66 | uses: actions/setup-node@v1 67 | with: 68 | node-version: ${{ env.NODE_VERSION_USED_FOR_DEVELOPMENT }} 69 | 70 | - name: Run npm install 71 | run: npm install --package-lock-only --engine-strict --strict-peer-deps 72 | 73 | - name: Check that package-lock.json is in sync with package.json 74 | run: git diff --exit-code package-lock.json 75 | 76 | coverage: 77 | name: Measure test coverage 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Checkout repo 81 | uses: actions/checkout@v2 82 | 83 | - name: Setup Node.js 84 | uses: actions/setup-node@v1 85 | with: 86 | node-version: ${{ env.NODE_VERSION_USED_FOR_DEVELOPMENT }} 87 | 88 | - name: Cache Node.js modules 89 | uses: actions/cache@v2 90 | with: 91 | path: ~/.npm 92 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 93 | restore-keys: | 94 | ${{ runner.OS }}-node- 95 | 96 | - name: Install Dependencies 97 | run: npm ci 98 | 99 | - name: Run tests and measure code coverage 100 | run: npm run testonly:cover 101 | 102 | - name: Upload coverage to Codecov 103 | if: ${{ always() }} 104 | uses: codecov/codecov-action@v4 105 | with: 106 | file: ./coverage/coverage-final.json 107 | fail_ci_if_error: true 108 | token: ${{ secrets.CODECOV_TOKEN }} 109 | 110 | test: 111 | name: Run tests on Node v${{ matrix.node_version_to_setup }} 112 | runs-on: ubuntu-latest 113 | strategy: 114 | matrix: 115 | node_version_to_setup: [12, 14, 15] 116 | steps: 117 | - name: Checkout repo 118 | uses: actions/checkout@v2 119 | 120 | - name: Setup Node.js v${{ matrix.node_version_to_setup }} 121 | uses: actions/setup-node@v1 122 | with: 123 | node-version: ${{ matrix.node_version_to_setup }} 124 | 125 | - name: Cache Node.js modules 126 | uses: actions/cache@v2 127 | with: 128 | path: ~/.npm 129 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 130 | restore-keys: | 131 | ${{ runner.OS }}-node- 132 | 133 | - name: Install Dependencies 134 | run: npm ci 135 | 136 | - name: Run Tests 137 | run: npm run testonly 138 | -------------------------------------------------------------------------------- /resources/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const assert = require('assert'); 6 | 7 | const ts = require('typescript'); 8 | const babel = require('@babel/core'); 9 | 10 | const { 11 | writeGeneratedFile, 12 | readdirRecursive, 13 | showDirStats, 14 | } = require('./utils'); 15 | 16 | if (require.main === module) { 17 | fs.rmSync('./npmDist', { recursive: true, force: true }); 18 | fs.mkdirSync('./npmDist'); 19 | 20 | const packageJSON = buildPackageJSON(); 21 | 22 | const srcFiles = readdirRecursive('./src', { ignoreDir: /^__.*__$/ }); 23 | for (const filepath of srcFiles) { 24 | const srcPath = path.join('./src', filepath); 25 | const destPath = path.join('./npmDist', filepath); 26 | 27 | fs.mkdirSync(path.dirname(destPath), { recursive: true }); 28 | if (filepath.endsWith('.ts')) { 29 | const cjs = babelBuild(srcPath, { envName: 'cjs' }); 30 | writeGeneratedFile(destPath.replace(/\.ts$/, '.js'), cjs); 31 | } 32 | } 33 | 34 | // Based on https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API#getting-the-dts-from-a-javascript-file 35 | const tsConfig = JSON.parse( 36 | fs.readFileSync(require.resolve('../tsconfig.json'), 'utf-8'), 37 | ); 38 | assert( 39 | tsConfig.compilerOptions, 40 | '"tsconfig.json" should have `compilerOptions`', 41 | ); 42 | const tsOptions = { 43 | ...tsConfig.compilerOptions, 44 | noEmit: false, 45 | declaration: true, 46 | declarationDir: './npmDist', 47 | emitDeclarationOnly: true, 48 | }; 49 | 50 | const tsHost = ts.createCompilerHost(tsOptions); 51 | tsHost.writeFile = (filepath, body) => { 52 | writeGeneratedFile(filepath, body); 53 | }; 54 | 55 | const tsProgram = ts.createProgram(['src/index.ts'], tsOptions, tsHost); 56 | const tsResult = tsProgram.emit(); 57 | assert( 58 | !tsResult.emitSkipped, 59 | 'Fail to generate `*.d.ts` files, please run `npm run check`', 60 | ); 61 | 62 | assert(packageJSON.types === undefined, 'Unexpected "types" in package.json'); 63 | const supportedTSVersions = Object.keys(packageJSON.typesVersions); 64 | assert( 65 | supportedTSVersions.length === 1, 66 | 'Property "typesVersions" should have exactly one key.', 67 | ); 68 | // TODO: revisit once TS implements https://github.com/microsoft/TypeScript/issues/32166 69 | const notSupportedTSVersionFile = 'NotSupportedTSVersion.d.ts'; 70 | fs.writeFileSync( 71 | path.join('./npmDist', notSupportedTSVersionFile), 72 | // Provoke syntax error to show this message 73 | `"Package 'graphql' support only TS versions that are ${supportedTSVersions[0]}".`, 74 | ); 75 | packageJSON.typesVersions = { 76 | ...packageJSON.typesVersions, 77 | '*': { '*': [notSupportedTSVersionFile] }, 78 | }; 79 | 80 | fs.copyFileSync('./LICENSE', './npmDist/LICENSE'); 81 | fs.copyFileSync('./README.md', './npmDist/README.md'); 82 | 83 | // Should be done as the last step so only valid packages can be published 84 | writeGeneratedFile('./npmDist/package.json', JSON.stringify(packageJSON)); 85 | 86 | showDirStats('./npmDist'); 87 | } 88 | 89 | function babelBuild(srcPath, options) { 90 | const { code } = babel.transformFileSync(srcPath, { 91 | babelrc: false, 92 | configFile: './.babelrc-npm.json', 93 | ...options, 94 | }); 95 | return code + '\n'; 96 | } 97 | 98 | function buildPackageJSON() { 99 | const packageJSON = JSON.parse( 100 | fs.readFileSync(require.resolve('../package.json'), 'utf-8'), 101 | ); 102 | 103 | delete packageJSON.private; 104 | delete packageJSON.scripts; 105 | delete packageJSON.devDependencies; 106 | 107 | // TODO: move to integration tests 108 | const publishTag = packageJSON.publishConfig?.tag; 109 | assert(publishTag != null, 'Should have packageJSON.publishConfig defined!'); 110 | 111 | const { version } = packageJSON; 112 | const versionMatch = /^\d+\.\d+\.\d+-?(?.*)?$/.exec(version); 113 | if (!versionMatch) { 114 | throw new Error('Version does not match semver spec: ' + version); 115 | } 116 | 117 | const { preReleaseTag } = versionMatch.groups; 118 | 119 | if (preReleaseTag != null) { 120 | const splittedTag = preReleaseTag.split('.'); 121 | // Note: `experimental-*` take precedence over `alpha`, `beta` or `rc`. 122 | const versionTag = splittedTag[2] ?? splittedTag[0]; 123 | assert( 124 | ['alpha', 'beta', 'rc'].includes(versionTag) || 125 | versionTag.startsWith('experimental-'), 126 | `"${versionTag}" tag is not supported.`, 127 | ); 128 | assert.equal( 129 | versionTag, 130 | publishTag, 131 | 'Publish tag and version tag should match!', 132 | ); 133 | } 134 | 135 | return packageJSON; 136 | } 137 | -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | export type Base64String = string; 2 | 3 | export function base64(input: string): Base64String { 4 | const utf8Array = stringToUTF8Array(input); 5 | let result = ''; 6 | 7 | const length = utf8Array.length; 8 | const rest = length % 3; 9 | for (let i = 0; i < length - rest; i += 3) { 10 | const a = utf8Array[i]; 11 | const b = utf8Array[i + 1]; 12 | const c = utf8Array[i + 2]; 13 | 14 | result += first6Bits(a); 15 | result += last2BitsAndFirst4Bits(a, b); 16 | result += last4BitsAndFirst2Bits(b, c); 17 | result += last6Bits(c); 18 | } 19 | 20 | if (rest === 1) { 21 | const a = utf8Array[length - 1]; 22 | result += first6Bits(a) + last2BitsAndFirst4Bits(a, 0) + '=='; 23 | } else if (rest === 2) { 24 | const a = utf8Array[length - 2]; 25 | const b = utf8Array[length - 1]; 26 | result += 27 | first6Bits(a) + 28 | last2BitsAndFirst4Bits(a, b) + 29 | last4BitsAndFirst2Bits(b, 0) + 30 | '='; 31 | } 32 | 33 | return result; 34 | } 35 | 36 | function first6Bits(a: number): string { 37 | return toBase64Char((a >> 2) & 63); 38 | } 39 | 40 | function last2BitsAndFirst4Bits(a: number, b: number): string { 41 | return toBase64Char(((a << 4) | (b >> 4)) & 63); 42 | } 43 | 44 | function last4BitsAndFirst2Bits(b: number, c: number): string { 45 | return toBase64Char(((b << 2) | (c >> 6)) & 63); 46 | } 47 | 48 | function last6Bits(c: number): string { 49 | return toBase64Char(c & 63); 50 | } 51 | 52 | export function unbase64(input: Base64String): string { 53 | const utf8Array = []; 54 | 55 | for (let i = 0; i < input.length; i += 4) { 56 | const a = fromBase64Char(input[i]); 57 | const b = fromBase64Char(input[i + 1]); 58 | const c = fromBase64Char(input[i + 2]); 59 | const d = fromBase64Char(input[i + 3]); 60 | 61 | if (a === -1 || b === -1 || c === -1 || d === -1) { 62 | /* 63 | * Previously we used Node's API for parsing Base64 and following code 64 | * Buffer.from(i, 'utf8').toString('base64') 65 | * That silently ignored incorrect input and returned empty string instead 66 | * Let's keep this behaviour for a time being and hopefully fix it in the future. 67 | */ 68 | return ''; 69 | } 70 | 71 | const bitmap24 = (a << 18) | (b << 12) | (c << 6) | d; 72 | utf8Array.push((bitmap24 >> 16) & 255); 73 | utf8Array.push((bitmap24 >> 8) & 255); 74 | utf8Array.push(bitmap24 & 255); 75 | } 76 | 77 | let paddingIndex = input.length - 1; 78 | while (input[paddingIndex] === '=') { 79 | --paddingIndex; 80 | utf8Array.pop(); 81 | } 82 | 83 | return utf8ArrayToString(utf8Array); 84 | } 85 | 86 | const b64CharacterSet = 87 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; 88 | 89 | function toBase64Char(bitMap6: number): string { 90 | return b64CharacterSet.charAt(bitMap6); 91 | } 92 | 93 | function fromBase64Char(base64Char: string | undefined): number { 94 | if (base64Char === undefined) { 95 | return -1; 96 | } 97 | return base64Char === '=' ? 0 : b64CharacterSet.indexOf(base64Char); 98 | } 99 | 100 | function stringToUTF8Array(input: string): Array { 101 | const result = []; 102 | for (const utfChar of input) { 103 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 104 | const code = utfChar.codePointAt(0)!; 105 | if (code < 0x80) { 106 | result.push(code); 107 | } else if (code < 0x800) { 108 | result.push(0xc0 | (code >> 6)); 109 | result.push(0x80 | (code & 0x3f)); 110 | } else if (code < 0x10000) { 111 | result.push(0xe0 | (code >> 12)); 112 | result.push(0x80 | ((code >> 6) & 0x3f)); 113 | result.push(0x80 | (code & 0x3f)); 114 | } else { 115 | result.push(0xf0 | (code >> 18)); 116 | result.push(0x80 | ((code >> 12) & 0x3f)); 117 | result.push(0x80 | ((code >> 6) & 0x3f)); 118 | result.push(0x80 | (code & 0x3f)); 119 | } 120 | } 121 | return result; 122 | } 123 | 124 | function utf8ArrayToString(input: Array) { 125 | let result = ''; 126 | for (let i = 0; i < input.length; ) { 127 | const a = input[i++]; 128 | if ((a & 0x80) === 0) { 129 | result += fromCodePoint(a); 130 | continue; 131 | } 132 | 133 | const b = input[i++]; 134 | if ((a & 0xe0) === 0xc0) { 135 | result += fromCodePoint(((a & 0x1f) << 6) | (b & 0x3f)); 136 | continue; 137 | } 138 | 139 | const c = input[i++]; 140 | if ((a & 0xf0) === 0xe0) { 141 | result += fromCodePoint( 142 | ((a & 0x0f) << 12) | ((b & 0x3f) << 6) | (c & 0x3f), 143 | ); 144 | continue; 145 | } 146 | 147 | const d = input[i++]; 148 | result += fromCodePoint( 149 | ((a & 0x07) << 18) | ((b & 0x3f) << 12) | ((c & 0x3f) << 6) | (d & 0x3f), 150 | ); 151 | } 152 | 153 | return result; 154 | } 155 | 156 | function fromCodePoint(code: number): string { 157 | if (code > 0x10ffff) { 158 | /* 159 | * Previously we used Node's API for parsing Base64 and following code 160 | * Buffer.from(i, 'base64').toString('utf8') 161 | * That silently ignored incorrect input and returned empty string instead 162 | * Let's keep this behaviour for a time being and hopefully fix it in the future. 163 | */ 164 | return ''; 165 | } 166 | return String.fromCodePoint(code); 167 | } 168 | -------------------------------------------------------------------------------- /src/__tests__/starWarsConnection-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | import { graphqlSync } from 'graphql'; 4 | 5 | import { StarWarsSchema as schema } from './starWarsSchema'; 6 | 7 | describe('Star Wars connections', () => { 8 | it('fetches the first ship of the rebels', () => { 9 | const source = ` 10 | { 11 | rebels { 12 | name, 13 | ships(first: 1) { 14 | edges { 15 | node { 16 | name 17 | } 18 | } 19 | } 20 | } 21 | } 22 | `; 23 | 24 | expect(graphqlSync({ schema, source })).to.deep.equal({ 25 | data: { 26 | rebels: { 27 | name: 'Alliance to Restore the Republic', 28 | ships: { 29 | edges: [ 30 | { 31 | node: { name: 'X-Wing' }, 32 | }, 33 | ], 34 | }, 35 | }, 36 | }, 37 | }); 38 | }); 39 | 40 | it('fetches the first two ships of the rebels with a cursor', () => { 41 | const source = ` 42 | { 43 | rebels { 44 | name, 45 | ships(first: 2) { 46 | edges { 47 | cursor, 48 | node { 49 | name 50 | } 51 | } 52 | } 53 | } 54 | } 55 | `; 56 | 57 | expect(graphqlSync({ schema, source })).to.deep.equal({ 58 | data: { 59 | rebels: { 60 | name: 'Alliance to Restore the Republic', 61 | ships: { 62 | edges: [ 63 | { 64 | cursor: 'YXJyYXljb25uZWN0aW9uOjA=', 65 | node: { name: 'X-Wing' }, 66 | }, 67 | { 68 | cursor: 'YXJyYXljb25uZWN0aW9uOjE=', 69 | node: { name: 'Y-Wing' }, 70 | }, 71 | ], 72 | }, 73 | }, 74 | }, 75 | }); 76 | }); 77 | 78 | it('fetches the next three ships of the rebels with a cursor', () => { 79 | const source = ` 80 | { 81 | rebels { 82 | name, 83 | ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjE=") { 84 | edges { 85 | cursor, 86 | node { 87 | name 88 | } 89 | } 90 | } 91 | } 92 | } 93 | `; 94 | 95 | expect(graphqlSync({ schema, source })).to.deep.equal({ 96 | data: { 97 | rebels: { 98 | name: 'Alliance to Restore the Republic', 99 | ships: { 100 | edges: [ 101 | { 102 | cursor: 'YXJyYXljb25uZWN0aW9uOjI=', 103 | node: { name: 'A-Wing' }, 104 | }, 105 | { 106 | cursor: 'YXJyYXljb25uZWN0aW9uOjM=', 107 | node: { name: 'Millennium Falcon' }, 108 | }, 109 | { 110 | cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', 111 | node: { name: 'Home One' }, 112 | }, 113 | ], 114 | }, 115 | }, 116 | }, 117 | }); 118 | }); 119 | 120 | it('fetches no ships of the rebels at the end of connection', () => { 121 | const source = ` 122 | { 123 | rebels { 124 | name, 125 | ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjQ=") { 126 | edges { 127 | cursor, 128 | node { 129 | name 130 | } 131 | } 132 | } 133 | } 134 | } 135 | `; 136 | 137 | expect(graphqlSync({ schema, source })).to.deep.equal({ 138 | data: { 139 | rebels: { 140 | name: 'Alliance to Restore the Republic', 141 | ships: { 142 | edges: [], 143 | }, 144 | }, 145 | }, 146 | }); 147 | }); 148 | 149 | it('identifies the end of the list', () => { 150 | const source = ` 151 | { 152 | rebels { 153 | name, 154 | originalShips: ships(first: 2) { 155 | edges { 156 | node { 157 | name 158 | } 159 | } 160 | pageInfo { 161 | hasNextPage 162 | } 163 | } 164 | moreShips: ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjE=") { 165 | edges { 166 | node { 167 | name 168 | } 169 | } 170 | pageInfo { 171 | hasNextPage 172 | } 173 | } 174 | } 175 | } 176 | `; 177 | 178 | expect(graphqlSync({ schema, source })).to.deep.equal({ 179 | data: { 180 | rebels: { 181 | name: 'Alliance to Restore the Republic', 182 | originalShips: { 183 | edges: [ 184 | { 185 | node: { name: 'X-Wing' }, 186 | }, 187 | { 188 | node: { name: 'Y-Wing' }, 189 | }, 190 | ], 191 | pageInfo: { hasNextPage: true }, 192 | }, 193 | moreShips: { 194 | edges: [ 195 | { 196 | node: { name: 'A-Wing' }, 197 | }, 198 | { 199 | node: { name: 'Millennium Falcon' }, 200 | }, 201 | { 202 | node: { name: 'Home One' }, 203 | }, 204 | ], 205 | pageInfo: { hasNextPage: false }, 206 | }, 207 | }, 208 | }, 209 | }); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /src/connection/arrayConnection.ts: -------------------------------------------------------------------------------- 1 | import { base64, unbase64 } from '../utils/base64'; 2 | 3 | import type { 4 | Connection, 5 | ConnectionArguments, 6 | ConnectionCursor, 7 | } from './connection'; 8 | 9 | interface ArraySliceMetaInfo { 10 | sliceStart: number; 11 | arrayLength: number; 12 | } 13 | 14 | /** 15 | * A simple function that accepts an array and connection arguments, and returns 16 | * a connection object for use in GraphQL. It uses array offsets as pagination, 17 | * so pagination will only work if the array is static. 18 | */ 19 | export function connectionFromArray( 20 | data: ReadonlyArray, 21 | args: ConnectionArguments, 22 | ): Connection { 23 | return connectionFromArraySlice(data, args, { 24 | sliceStart: 0, 25 | arrayLength: data.length, 26 | }); 27 | } 28 | 29 | /** 30 | * A version of `connectionFromArray` that takes a promised array, and returns a 31 | * promised connection. 32 | */ 33 | export function connectionFromPromisedArray( 34 | dataPromise: Promise>, 35 | args: ConnectionArguments, 36 | ): Promise> { 37 | return dataPromise.then((data) => connectionFromArray(data, args)); 38 | } 39 | 40 | /** 41 | * Given a slice (subset) of an array, returns a connection object for use in 42 | * GraphQL. 43 | * 44 | * This function is similar to `connectionFromArray`, but is intended for use 45 | * cases where you know the cardinality of the connection, consider it too large 46 | * to materialize the entire array, and instead wish pass in a slice of the 47 | * total result large enough to cover the range specified in `args`. 48 | */ 49 | export function connectionFromArraySlice( 50 | arraySlice: ReadonlyArray, 51 | args: ConnectionArguments, 52 | meta: ArraySliceMetaInfo, 53 | ): Connection { 54 | const { after, before, first, last } = args; 55 | const { sliceStart, arrayLength } = meta; 56 | const sliceEnd = sliceStart + arraySlice.length; 57 | 58 | let startOffset = Math.max(sliceStart, 0); 59 | let endOffset = Math.min(sliceEnd, arrayLength); 60 | 61 | const afterOffset = getOffsetWithDefault(after, -1); 62 | if (0 <= afterOffset && afterOffset < arrayLength) { 63 | startOffset = Math.max(startOffset, afterOffset + 1); 64 | } 65 | 66 | const beforeOffset = getOffsetWithDefault(before, endOffset); 67 | if (0 <= beforeOffset && beforeOffset < arrayLength) { 68 | endOffset = Math.min(endOffset, beforeOffset); 69 | } 70 | 71 | if (typeof first === 'number') { 72 | if (first < 0) { 73 | throw new Error('Argument "first" must be a non-negative integer'); 74 | } 75 | 76 | endOffset = Math.min(endOffset, startOffset + first); 77 | } 78 | if (typeof last === 'number') { 79 | if (last < 0) { 80 | throw new Error('Argument "last" must be a non-negative integer'); 81 | } 82 | 83 | startOffset = Math.max(startOffset, endOffset - last); 84 | } 85 | 86 | // If supplied slice is too large, trim it down before mapping over it. 87 | const slice = arraySlice.slice( 88 | startOffset - sliceStart, 89 | endOffset - sliceStart, 90 | ); 91 | 92 | const edges = slice.map((value, index) => ({ 93 | cursor: offsetToCursor(startOffset + index), 94 | node: value, 95 | })); 96 | 97 | const firstEdge = edges[0]; 98 | const lastEdge = edges[edges.length - 1]; 99 | const lowerBound = after != null ? afterOffset + 1 : 0; 100 | const upperBound = before != null ? beforeOffset : arrayLength; 101 | return { 102 | edges, 103 | pageInfo: { 104 | startCursor: firstEdge ? firstEdge.cursor : null, 105 | endCursor: lastEdge ? lastEdge.cursor : null, 106 | hasPreviousPage: 107 | typeof last === 'number' ? startOffset > lowerBound : false, 108 | hasNextPage: typeof first === 'number' ? endOffset < upperBound : false, 109 | }, 110 | }; 111 | } 112 | 113 | /** 114 | * A version of `connectionFromArraySlice` that takes a promised array slice, 115 | * and returns a promised connection. 116 | */ 117 | export function connectionFromPromisedArraySlice( 118 | dataPromise: Promise>, 119 | args: ConnectionArguments, 120 | arrayInfo: ArraySliceMetaInfo, 121 | ): Promise> { 122 | return dataPromise.then((data) => 123 | connectionFromArraySlice(data, args, arrayInfo), 124 | ); 125 | } 126 | 127 | const PREFIX = 'arrayconnection:'; 128 | 129 | /** 130 | * Creates the cursor string from an offset. 131 | */ 132 | export function offsetToCursor(offset: number): ConnectionCursor { 133 | return base64(PREFIX + offset.toString()); 134 | } 135 | 136 | /** 137 | * Extracts the offset from the cursor string. 138 | */ 139 | export function cursorToOffset(cursor: ConnectionCursor): number { 140 | return parseInt(unbase64(cursor).substring(PREFIX.length), 10); 141 | } 142 | 143 | /** 144 | * Return the cursor associated with an object in an array. 145 | */ 146 | export function cursorForObjectInConnection( 147 | data: ReadonlyArray, 148 | object: T, 149 | ): ConnectionCursor | null { 150 | const offset = data.indexOf(object); 151 | if (offset === -1) { 152 | return null; 153 | } 154 | return offsetToCursor(offset); 155 | } 156 | 157 | /** 158 | * Given an optional cursor and a default offset, returns the offset 159 | * to use; if the cursor contains a valid offset, that will be used, 160 | * otherwise it will be the default. 161 | */ 162 | export function getOffsetWithDefault( 163 | cursor: ConnectionCursor | null | undefined, 164 | defaultOffset: number, 165 | ): number { 166 | if (typeof cursor !== 'string') { 167 | return defaultOffset; 168 | } 169 | const offset = cursorToOffset(cursor); 170 | return isNaN(offset) ? defaultOffset : offset; 171 | } 172 | -------------------------------------------------------------------------------- /src/connection/connection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLList, 3 | GraphQLNonNull, 4 | GraphQLObjectType, 5 | GraphQLInt, 6 | GraphQLString, 7 | GraphQLBoolean, 8 | getNamedType, 9 | resolveObjMapThunk, 10 | } from 'graphql'; 11 | 12 | import type { 13 | GraphQLNamedOutputType, 14 | GraphQLFieldConfigArgumentMap, 15 | GraphQLFieldConfig, 16 | GraphQLFieldResolver, 17 | ThunkObjMap, 18 | } from 'graphql'; 19 | 20 | /** 21 | * Returns a GraphQLFieldConfigArgumentMap appropriate to include on a field 22 | * whose return type is a connection type with forward pagination. 23 | */ 24 | export const forwardConnectionArgs: GraphQLFieldConfigArgumentMap = 25 | Object.freeze({ 26 | after: { 27 | type: GraphQLString, 28 | description: 29 | 'Returns the items in the list that come after the specified cursor.', 30 | }, 31 | first: { 32 | type: GraphQLInt, 33 | description: 'Returns the first n items from the list.', 34 | }, 35 | }); 36 | 37 | /** 38 | * Returns a GraphQLFieldConfigArgumentMap appropriate to include on a field 39 | * whose return type is a connection type with backward pagination. 40 | */ 41 | export const backwardConnectionArgs: GraphQLFieldConfigArgumentMap = 42 | Object.freeze({ 43 | before: { 44 | type: GraphQLString, 45 | description: 46 | 'Returns the items in the list that come before the specified cursor.', 47 | }, 48 | last: { 49 | type: GraphQLInt, 50 | description: 'Returns the last n items from the list.', 51 | }, 52 | }); 53 | 54 | /** 55 | * Returns a GraphQLFieldConfigArgumentMap appropriate to include on a field 56 | * whose return type is a connection type with bidirectional pagination. 57 | */ 58 | export const connectionArgs: GraphQLFieldConfigArgumentMap = { 59 | ...forwardConnectionArgs, 60 | ...backwardConnectionArgs, 61 | }; 62 | 63 | /** 64 | * A type alias for cursors in this implementation. 65 | */ 66 | export type ConnectionCursor = string; 67 | 68 | /** 69 | * A type describing the arguments a connection field receives in GraphQL. 70 | */ 71 | export interface ConnectionArguments { 72 | before?: ConnectionCursor | null; 73 | after?: ConnectionCursor | null; 74 | first?: number | null; 75 | last?: number | null; 76 | } 77 | 78 | export interface ConnectionConfig { 79 | name?: string; 80 | nodeType: GraphQLNamedOutputType | GraphQLNonNull; 81 | resolveNode?: GraphQLFieldResolver; 82 | resolveCursor?: GraphQLFieldResolver; 83 | edgeFields?: ThunkObjMap>; 84 | connectionFields?: ThunkObjMap>; 85 | } 86 | 87 | export interface GraphQLConnectionDefinitions { 88 | edgeType: GraphQLObjectType; 89 | connectionType: GraphQLObjectType; 90 | } 91 | 92 | /** 93 | * Returns a GraphQLObjectType for a connection with the given name, 94 | * and whose nodes are of the specified type. 95 | */ 96 | export function connectionDefinitions( 97 | config: ConnectionConfig, 98 | ): GraphQLConnectionDefinitions { 99 | const { nodeType } = config; 100 | const name = config.name ?? getNamedType(nodeType).name; 101 | const edgeType = new GraphQLObjectType({ 102 | name: name + 'Edge', 103 | description: 'An edge in a connection.', 104 | fields: () => ({ 105 | node: { 106 | type: nodeType, 107 | resolve: config.resolveNode, 108 | description: 'The item at the end of the edge', 109 | }, 110 | cursor: { 111 | type: new GraphQLNonNull(GraphQLString), 112 | resolve: config.resolveCursor, 113 | description: 'A cursor for use in pagination', 114 | }, 115 | ...resolveObjMapThunk(config.edgeFields ?? {}), 116 | }), 117 | }); 118 | 119 | const connectionType = new GraphQLObjectType({ 120 | name: name + 'Connection', 121 | description: 'A connection to a list of items.', 122 | fields: () => ({ 123 | pageInfo: { 124 | type: new GraphQLNonNull(pageInfoType), 125 | description: 'Information to aid in pagination.', 126 | }, 127 | edges: { 128 | type: new GraphQLList(edgeType), 129 | description: 'A list of edges.', 130 | }, 131 | ...resolveObjMapThunk(config.connectionFields ?? {}), 132 | }), 133 | }); 134 | 135 | return { edgeType, connectionType }; 136 | } 137 | 138 | /** 139 | * A type designed to be exposed as a `Connection` over GraphQL. 140 | */ 141 | export interface Connection { 142 | edges: Array>; 143 | pageInfo: PageInfo; 144 | } 145 | 146 | /** 147 | * A type designed to be exposed as a `Edge` over GraphQL. 148 | */ 149 | export interface Edge { 150 | node: T; 151 | cursor: ConnectionCursor; 152 | } 153 | 154 | /** 155 | * The common page info type used by all connections. 156 | */ 157 | const pageInfoType = new GraphQLObjectType({ 158 | name: 'PageInfo', 159 | description: 'Information about pagination in a connection.', 160 | fields: () => ({ 161 | hasNextPage: { 162 | type: new GraphQLNonNull(GraphQLBoolean), 163 | description: 'When paginating forwards, are there more items?', 164 | }, 165 | hasPreviousPage: { 166 | type: new GraphQLNonNull(GraphQLBoolean), 167 | description: 'When paginating backwards, are there more items?', 168 | }, 169 | startCursor: { 170 | type: GraphQLString, 171 | description: 'When paginating backwards, the cursor to continue.', 172 | }, 173 | endCursor: { 174 | type: GraphQLString, 175 | description: 'When paginating forwards, the cursor to continue.', 176 | }, 177 | }), 178 | }); 179 | 180 | /** 181 | * A type designed to be exposed as `PageInfo` over GraphQL. 182 | */ 183 | export interface PageInfo { 184 | startCursor: ConnectionCursor | null; 185 | endCursor: ConnectionCursor | null; 186 | hasPreviousPage: boolean; 187 | hasNextPage: boolean; 188 | } 189 | -------------------------------------------------------------------------------- /src/connection/__tests__/connection-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | import { 4 | GraphQLInt, 5 | GraphQLNonNull, 6 | GraphQLObjectType, 7 | GraphQLSchema, 8 | GraphQLString, 9 | graphqlSync, 10 | printSchema, 11 | } from 'graphql'; 12 | 13 | import { dedent } from '../../__testUtils__/dedent'; 14 | 15 | import { connectionFromArray } from '../arrayConnection'; 16 | 17 | import { 18 | backwardConnectionArgs, 19 | connectionArgs, 20 | connectionDefinitions, 21 | forwardConnectionArgs, 22 | } from '../connection'; 23 | 24 | const allUsers = [ 25 | { name: 'Dan', friends: [1, 2, 3, 4] }, 26 | { name: 'Nick', friends: [0, 2, 3, 4] }, 27 | { name: 'Lee', friends: [0, 1, 3, 4] }, 28 | { name: 'Joe', friends: [0, 1, 2, 4] }, 29 | { name: 'Tim', friends: [0, 1, 2, 3] }, 30 | ]; 31 | 32 | const userType = new GraphQLObjectType({ 33 | name: 'User', 34 | fields: () => ({ 35 | name: { 36 | type: GraphQLString, 37 | }, 38 | friends: { 39 | type: friendConnection, 40 | args: connectionArgs, 41 | resolve: (user, args) => connectionFromArray(user.friends, args), 42 | }, 43 | friendsForward: { 44 | type: userConnection, 45 | args: forwardConnectionArgs, 46 | resolve: (user, args) => connectionFromArray(user.friends, args), 47 | }, 48 | friendsBackward: { 49 | type: userConnection, 50 | args: backwardConnectionArgs, 51 | resolve: (user, args) => connectionFromArray(user.friends, args), 52 | }, 53 | }), 54 | }); 55 | 56 | const { connectionType: friendConnection } = connectionDefinitions({ 57 | name: 'Friend', 58 | nodeType: new GraphQLNonNull(userType), 59 | resolveNode: (edge) => allUsers[edge.node], 60 | edgeFields: () => ({ 61 | friendshipTime: { 62 | type: GraphQLString, 63 | resolve: () => 'Yesterday', 64 | }, 65 | }), 66 | connectionFields: () => ({ 67 | totalCount: { 68 | type: GraphQLInt, 69 | resolve: () => allUsers.length - 1, 70 | }, 71 | }), 72 | }); 73 | 74 | const { connectionType: userConnection } = connectionDefinitions({ 75 | nodeType: new GraphQLNonNull(userType), 76 | resolveNode: (edge) => allUsers[edge.node], 77 | }); 78 | 79 | const queryType = new GraphQLObjectType({ 80 | name: 'Query', 81 | fields: () => ({ 82 | user: { 83 | type: userType, 84 | resolve: () => allUsers[0], 85 | }, 86 | }), 87 | }); 88 | 89 | const schema = new GraphQLSchema({ 90 | query: queryType, 91 | }); 92 | 93 | describe('connectionDefinition()', () => { 94 | it('includes connection and edge fields', () => { 95 | const source = ` 96 | { 97 | user { 98 | friends(first: 2) { 99 | totalCount 100 | edges { 101 | friendshipTime 102 | node { 103 | name 104 | } 105 | } 106 | } 107 | } 108 | } 109 | `; 110 | 111 | expect(graphqlSync({ schema, source })).to.deep.equal({ 112 | data: { 113 | user: { 114 | friends: { 115 | totalCount: 4, 116 | edges: [ 117 | { 118 | friendshipTime: 'Yesterday', 119 | node: { name: 'Nick' }, 120 | }, 121 | { 122 | friendshipTime: 'Yesterday', 123 | node: { name: 'Lee' }, 124 | }, 125 | ], 126 | }, 127 | }, 128 | }, 129 | }); 130 | }); 131 | 132 | it('works with forwardConnectionArgs', () => { 133 | const source = ` 134 | query FriendsQuery { 135 | user { 136 | friendsForward(first: 2) { 137 | edges { 138 | node { 139 | name 140 | } 141 | } 142 | } 143 | } 144 | } 145 | `; 146 | 147 | expect(graphqlSync({ schema, source })).to.deep.equal({ 148 | data: { 149 | user: { 150 | friendsForward: { 151 | edges: [{ node: { name: 'Nick' } }, { node: { name: 'Lee' } }], 152 | }, 153 | }, 154 | }, 155 | }); 156 | }); 157 | 158 | it('works with backwardConnectionArgs', () => { 159 | const source = ` 160 | { 161 | user { 162 | friendsBackward(last: 2) { 163 | edges { 164 | node { 165 | name 166 | } 167 | } 168 | } 169 | } 170 | } 171 | `; 172 | 173 | expect(graphqlSync({ schema, source })).to.deep.equal({ 174 | data: { 175 | user: { 176 | friendsBackward: { 177 | edges: [{ node: { name: 'Joe' } }, { node: { name: 'Tim' } }], 178 | }, 179 | }, 180 | }, 181 | }); 182 | }); 183 | 184 | it('generates correct types', () => { 185 | expect(printSchema(schema)).to.deep.equal(dedent` 186 | type Query { 187 | user: User 188 | } 189 | 190 | type User { 191 | name: String 192 | friends( 193 | """Returns the items in the list that come after the specified cursor.""" 194 | after: String 195 | 196 | """Returns the first n items from the list.""" 197 | first: Int 198 | 199 | """Returns the items in the list that come before the specified cursor.""" 200 | before: String 201 | 202 | """Returns the last n items from the list.""" 203 | last: Int 204 | ): FriendConnection 205 | friendsForward( 206 | """Returns the items in the list that come after the specified cursor.""" 207 | after: String 208 | 209 | """Returns the first n items from the list.""" 210 | first: Int 211 | ): UserConnection 212 | friendsBackward( 213 | """Returns the items in the list that come before the specified cursor.""" 214 | before: String 215 | 216 | """Returns the last n items from the list.""" 217 | last: Int 218 | ): UserConnection 219 | } 220 | 221 | """A connection to a list of items.""" 222 | type FriendConnection { 223 | """Information to aid in pagination.""" 224 | pageInfo: PageInfo! 225 | 226 | """A list of edges.""" 227 | edges: [FriendEdge] 228 | totalCount: Int 229 | } 230 | 231 | """Information about pagination in a connection.""" 232 | type PageInfo { 233 | """When paginating forwards, are there more items?""" 234 | hasNextPage: Boolean! 235 | 236 | """When paginating backwards, are there more items?""" 237 | hasPreviousPage: Boolean! 238 | 239 | """When paginating backwards, the cursor to continue.""" 240 | startCursor: String 241 | 242 | """When paginating forwards, the cursor to continue.""" 243 | endCursor: String 244 | } 245 | 246 | """An edge in a connection.""" 247 | type FriendEdge { 248 | """The item at the end of the edge""" 249 | node: User! 250 | 251 | """A cursor for use in pagination""" 252 | cursor: String! 253 | friendshipTime: String 254 | } 255 | 256 | """A connection to a list of items.""" 257 | type UserConnection { 258 | """Information to aid in pagination.""" 259 | pageInfo: PageInfo! 260 | 261 | """A list of edges.""" 262 | edges: [UserEdge] 263 | } 264 | 265 | """An edge in a connection.""" 266 | type UserEdge { 267 | """The item at the end of the edge""" 268 | node: User! 269 | 270 | """A cursor for use in pagination""" 271 | cursor: String! 272 | } 273 | `); 274 | }); 275 | }); 276 | -------------------------------------------------------------------------------- /src/__tests__/starWarsSchema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLID, 3 | GraphQLNonNull, 4 | GraphQLObjectType, 5 | GraphQLSchema, 6 | GraphQLString, 7 | } from 'graphql'; 8 | 9 | import { nodeDefinitions, globalIdField, fromGlobalId } from '../node/node'; 10 | 11 | import { connectionFromArray } from '../connection/arrayConnection'; 12 | 13 | import { 14 | connectionArgs, 15 | connectionDefinitions, 16 | } from '../connection/connection'; 17 | 18 | import { mutationWithClientMutationId } from '../mutation/mutation'; 19 | 20 | import { 21 | getFaction, 22 | getShip, 23 | getRebels, 24 | getEmpire, 25 | createShip, 26 | } from './starWarsData'; 27 | 28 | /** 29 | * This is a basic end-to-end test, designed to demonstrate the various 30 | * capabilities of a Relay-compliant GraphQL server. 31 | * 32 | * It is recommended that readers of this test be familiar with 33 | * the end-to-end test in GraphQL.js first, as this test skips 34 | * over the basics covered there in favor of illustrating the 35 | * key aspects of the Relay spec that this test is designed to illustrate. 36 | * 37 | * We will create a GraphQL schema that describes the major 38 | * factions and ships in the original Star Wars trilogy. 39 | * 40 | * NOTE: This may contain spoilers for the original Star 41 | * Wars trilogy. 42 | */ 43 | 44 | /** 45 | * Using our shorthand to describe type systems, the type system for our 46 | * example will be the following: 47 | * 48 | * interface Node { 49 | * id: ID! 50 | * } 51 | * 52 | * type Faction : Node { 53 | * id: ID! 54 | * name: String 55 | * ships: ShipConnection 56 | * } 57 | * 58 | * type Ship : Node { 59 | * id: ID! 60 | * name: String 61 | * } 62 | * 63 | * type ShipConnection { 64 | * edges: [ShipEdge] 65 | * pageInfo: PageInfo! 66 | * } 67 | * 68 | * type ShipEdge { 69 | * cursor: String! 70 | * node: Ship 71 | * } 72 | * 73 | * type PageInfo { 74 | * hasNextPage: Boolean! 75 | * hasPreviousPage: Boolean! 76 | * startCursor: String 77 | * endCursor: String 78 | * } 79 | * 80 | * type Query { 81 | * rebels: Faction 82 | * empire: Faction 83 | * node(id: ID!): Node 84 | * } 85 | * 86 | * input IntroduceShipInput { 87 | * clientMutationId: string 88 | * shipName: string! 89 | * factionId: ID! 90 | * } 91 | * 92 | * type IntroduceShipPayload { 93 | * clientMutationId: string 94 | * ship: Ship 95 | * faction: Faction 96 | * } 97 | * 98 | * type Mutation { 99 | * introduceShip(input: IntroduceShipInput!): IntroduceShipPayload 100 | * } 101 | */ 102 | 103 | /** 104 | * We get the node interface and field from the relay library. 105 | * 106 | * The first method is the way we resolve an ID to its object. The second is the 107 | * way we resolve an object that implements node to its type. 108 | */ 109 | const { nodeInterface, nodeField } = nodeDefinitions( 110 | (globalId) => { 111 | const { type, id } = fromGlobalId(globalId); 112 | switch (type) { 113 | case 'Faction': 114 | return getFaction(id); 115 | case 'Ship': 116 | return getShip(id); 117 | } 118 | }, 119 | (obj) => (obj.ships ? factionType.name : shipType.name), 120 | ); 121 | 122 | /** 123 | * We define our basic ship type. 124 | * 125 | * This implements the following type system shorthand: 126 | * type Ship : Node { 127 | * id: String! 128 | * name: String 129 | * } 130 | */ 131 | const shipType: GraphQLObjectType = new GraphQLObjectType({ 132 | name: 'Ship', 133 | description: 'A ship in the Star Wars saga', 134 | interfaces: [nodeInterface], 135 | fields: () => ({ 136 | id: globalIdField(), 137 | name: { 138 | type: GraphQLString, 139 | description: 'The name of the ship.', 140 | }, 141 | }), 142 | }); 143 | 144 | /** 145 | * We define a connection between a faction and its ships. 146 | * 147 | * connectionType implements the following type system shorthand: 148 | * type ShipConnection { 149 | * edges: [ShipEdge] 150 | * pageInfo: PageInfo! 151 | * } 152 | * 153 | * connectionType has an edges field - a list of edgeTypes that implement the 154 | * following type system shorthand: 155 | * type ShipEdge { 156 | * cursor: String! 157 | * node: Ship 158 | * } 159 | */ 160 | const { connectionType: shipConnection } = connectionDefinitions({ 161 | nodeType: shipType, 162 | }); 163 | 164 | /** 165 | * We define our faction type, which implements the node interface. 166 | * 167 | * This implements the following type system shorthand: 168 | * type Faction : Node { 169 | * id: String! 170 | * name: String 171 | * ships: ShipConnection 172 | * } 173 | */ 174 | const factionType: GraphQLObjectType = new GraphQLObjectType({ 175 | name: 'Faction', 176 | description: 'A faction in the Star Wars saga', 177 | interfaces: [nodeInterface], 178 | fields: () => ({ 179 | id: globalIdField(), 180 | name: { 181 | type: GraphQLString, 182 | description: 'The name of the faction.', 183 | }, 184 | ships: { 185 | type: shipConnection, 186 | description: 'The ships used by the faction.', 187 | args: connectionArgs, 188 | resolve: (faction, args) => 189 | connectionFromArray(faction.ships.map(getShip), args), 190 | }, 191 | }), 192 | }); 193 | 194 | /** 195 | * This is the type that will be the root of our query, and the 196 | * entry point into our schema. 197 | * 198 | * This implements the following type system shorthand: 199 | * type Query { 200 | * rebels: Faction 201 | * empire: Faction 202 | * node(id: String!): Node 203 | * } 204 | */ 205 | const queryType = new GraphQLObjectType({ 206 | name: 'Query', 207 | fields: () => ({ 208 | rebels: { 209 | type: factionType, 210 | resolve: () => getRebels(), 211 | }, 212 | empire: { 213 | type: factionType, 214 | resolve: () => getEmpire(), 215 | }, 216 | node: nodeField, 217 | }), 218 | }); 219 | 220 | /** 221 | * This will return a GraphQLFieldConfig for our ship 222 | * mutation. 223 | * 224 | * It creates these two types implicitly: 225 | * input IntroduceShipInput { 226 | * clientMutationId: string 227 | * shipName: string! 228 | * factionId: ID! 229 | * } 230 | * 231 | * type IntroduceShipPayload { 232 | * clientMutationId: string 233 | * ship: Ship 234 | * faction: Faction 235 | * } 236 | */ 237 | const shipMutation = mutationWithClientMutationId({ 238 | name: 'IntroduceShip', 239 | inputFields: { 240 | shipName: { 241 | type: new GraphQLNonNull(GraphQLString), 242 | }, 243 | factionId: { 244 | type: new GraphQLNonNull(GraphQLID), 245 | }, 246 | }, 247 | outputFields: { 248 | ship: { 249 | type: shipType, 250 | resolve: (payload: any) => getShip(payload.shipId), 251 | }, 252 | faction: { 253 | type: factionType, 254 | resolve: (payload) => getFaction(payload.factionId), 255 | }, 256 | }, 257 | mutateAndGetPayload: ({ shipName, factionId }) => { 258 | const newShip = createShip(shipName, factionId); 259 | return { 260 | shipId: newShip.id, 261 | factionId, 262 | }; 263 | }, 264 | }); 265 | 266 | /** 267 | * This is the type that will be the root of our mutations, and the 268 | * entry point into performing writes in our schema. 269 | * 270 | * This implements the following type system shorthand: 271 | * type Mutation { 272 | * introduceShip(input IntroduceShipInput!): IntroduceShipPayload 273 | * } 274 | */ 275 | const mutationType = new GraphQLObjectType({ 276 | name: 'Mutation', 277 | fields: () => ({ 278 | introduceShip: shipMutation, 279 | }), 280 | }); 281 | 282 | /** 283 | * Finally, we construct our schema (whose starting query type is the query 284 | * type we defined above) and export it. 285 | */ 286 | export const StarWarsSchema: GraphQLSchema = new GraphQLSchema({ 287 | query: queryType, 288 | mutation: mutationType, 289 | }); 290 | -------------------------------------------------------------------------------- /src/node/__tests__/node-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import { 5 | GraphQLID, 6 | GraphQLInt, 7 | GraphQLNonNull, 8 | GraphQLObjectType, 9 | GraphQLSchema, 10 | GraphQLString, 11 | graphqlSync, 12 | printSchema, 13 | } from 'graphql'; 14 | 15 | import { dedent } from '../../__testUtils__/dedent'; 16 | 17 | import { nodeDefinitions } from '../node'; 18 | 19 | const userData = [ 20 | { 21 | id: '1', 22 | name: 'John Doe', 23 | }, 24 | { 25 | id: '2', 26 | name: 'Jane Smith', 27 | }, 28 | ]; 29 | 30 | const photoData = [ 31 | { 32 | id: '3', 33 | width: 300, 34 | }, 35 | { 36 | id: '4', 37 | width: 400, 38 | }, 39 | ]; 40 | 41 | const { nodeField, nodesField, nodeInterface } = nodeDefinitions( 42 | (id, _context, info) => { 43 | expect(info.schema).to.equal(schema); 44 | return ( 45 | userData.find((obj) => obj.id === id) ?? 46 | photoData.find((obj) => obj.id === id) 47 | ); 48 | }, 49 | (obj) => { 50 | if (userData.includes(obj)) { 51 | return userType.name; 52 | } 53 | // istanbul ignore else (Can't be reached) 54 | if (photoData.includes(obj)) { 55 | return photoType.name; 56 | } 57 | }, 58 | ); 59 | 60 | const userType: GraphQLObjectType = new GraphQLObjectType({ 61 | name: 'User', 62 | interfaces: [nodeInterface], 63 | fields: () => ({ 64 | id: { 65 | type: new GraphQLNonNull(GraphQLID), 66 | }, 67 | name: { 68 | type: GraphQLString, 69 | }, 70 | }), 71 | }); 72 | 73 | const photoType: GraphQLObjectType = new GraphQLObjectType({ 74 | name: 'Photo', 75 | interfaces: [nodeInterface], 76 | fields: () => ({ 77 | id: { 78 | type: new GraphQLNonNull(GraphQLID), 79 | }, 80 | width: { 81 | type: GraphQLInt, 82 | }, 83 | }), 84 | }); 85 | 86 | const queryType = new GraphQLObjectType({ 87 | name: 'Query', 88 | fields: () => ({ 89 | node: nodeField, 90 | nodes: nodesField, 91 | }), 92 | }); 93 | 94 | const schema = new GraphQLSchema({ 95 | query: queryType, 96 | types: [nodeInterface, userType, photoType], 97 | }); 98 | 99 | describe('Node interface and fields', () => { 100 | describe('Ability to refetch', () => { 101 | it('gets the correct ID for users', () => { 102 | const source = ` 103 | { 104 | node(id: "1") { 105 | id 106 | } 107 | } 108 | `; 109 | 110 | expect(graphqlSync({ schema, source })).to.deep.equal({ 111 | data: { 112 | node: { id: '1' }, 113 | }, 114 | }); 115 | }); 116 | 117 | it('gets the correct IDs for users', () => { 118 | const source = ` 119 | { 120 | nodes(ids: ["1", "2"]) { 121 | id 122 | } 123 | } 124 | `; 125 | 126 | expect(graphqlSync({ schema, source })).to.deep.equal({ 127 | data: { 128 | nodes: [{ id: '1' }, { id: '2' }], 129 | }, 130 | }); 131 | }); 132 | 133 | it('gets the correct ID for photos', () => { 134 | const source = ` 135 | { 136 | node(id: "4") { 137 | id 138 | } 139 | } 140 | `; 141 | 142 | expect(graphqlSync({ schema, source })).to.deep.equal({ 143 | data: { 144 | node: { id: '4' }, 145 | }, 146 | }); 147 | }); 148 | 149 | it('gets the correct IDs for photos', () => { 150 | const source = ` 151 | { 152 | nodes(ids: ["3", "4"]) { 153 | id 154 | } 155 | } 156 | `; 157 | 158 | expect(graphqlSync({ schema, source })).to.deep.equal({ 159 | data: { 160 | nodes: [{ id: '3' }, { id: '4' }], 161 | }, 162 | }); 163 | }); 164 | 165 | it('gets the correct IDs for multiple types', () => { 166 | const source = ` 167 | { 168 | nodes(ids: ["1", "3"]) { 169 | id 170 | } 171 | } 172 | `; 173 | 174 | expect(graphqlSync({ schema, source })).to.deep.equal({ 175 | data: { 176 | nodes: [{ id: '1' }, { id: '3' }], 177 | }, 178 | }); 179 | }); 180 | 181 | it('gets the correct name for users', () => { 182 | const source = ` 183 | { 184 | node(id: "1") { 185 | id 186 | ... on User { 187 | name 188 | } 189 | } 190 | } 191 | `; 192 | 193 | expect(graphqlSync({ schema, source })).to.deep.equal({ 194 | data: { 195 | node: { 196 | id: '1', 197 | name: 'John Doe', 198 | }, 199 | }, 200 | }); 201 | }); 202 | 203 | it('gets the correct width for photos', () => { 204 | const source = ` 205 | { 206 | node(id: "4") { 207 | id 208 | ... on Photo { 209 | width 210 | } 211 | } 212 | } 213 | `; 214 | 215 | expect(graphqlSync({ schema, source })).to.deep.equal({ 216 | data: { 217 | node: { 218 | id: '4', 219 | width: 400, 220 | }, 221 | }, 222 | }); 223 | }); 224 | 225 | it('gets the correct type name for users', () => { 226 | const source = ` 227 | { 228 | node(id: "1") { 229 | id 230 | __typename 231 | } 232 | } 233 | `; 234 | 235 | expect(graphqlSync({ schema, source })).to.deep.equal({ 236 | data: { 237 | node: { 238 | id: '1', 239 | __typename: 'User', 240 | }, 241 | }, 242 | }); 243 | }); 244 | 245 | it('gets the correct type name for photos', () => { 246 | const source = ` 247 | { 248 | node(id: "4") { 249 | id 250 | __typename 251 | } 252 | } 253 | `; 254 | 255 | expect(graphqlSync({ schema, source })).to.deep.equal({ 256 | data: { 257 | node: { 258 | id: '4', 259 | __typename: 'Photo', 260 | }, 261 | }, 262 | }); 263 | }); 264 | 265 | it('ignores photo fragments on user', () => { 266 | const source = ` 267 | { 268 | node(id: "1") { 269 | id 270 | ... on Photo { 271 | width 272 | } 273 | } 274 | } 275 | `; 276 | 277 | expect(graphqlSync({ schema, source })).to.deep.equal({ 278 | data: { 279 | node: { id: '1' }, 280 | }, 281 | }); 282 | }); 283 | 284 | it('returns null for bad IDs', () => { 285 | const source = ` 286 | { 287 | node(id: "5") { 288 | id 289 | } 290 | } 291 | `; 292 | 293 | expect(graphqlSync({ schema, source })).to.deep.equal({ 294 | data: { 295 | node: null, 296 | }, 297 | }); 298 | }); 299 | 300 | it('returns nulls for bad IDs', () => { 301 | const source = ` 302 | { 303 | nodes(ids: ["3", "5"]) { 304 | id 305 | } 306 | } 307 | `; 308 | 309 | expect(graphqlSync({ schema, source })).to.deep.equal({ 310 | data: { 311 | nodes: [{ id: '3' }, null], 312 | }, 313 | }); 314 | }); 315 | }); 316 | 317 | it('generates correct types', () => { 318 | expect(printSchema(schema)).to.deep.equal(dedent` 319 | """An object with an ID""" 320 | interface Node { 321 | """The id of the object.""" 322 | id: ID! 323 | } 324 | 325 | type User implements Node { 326 | id: ID! 327 | name: String 328 | } 329 | 330 | type Photo implements Node { 331 | id: ID! 332 | width: Int 333 | } 334 | 335 | type Query { 336 | """Fetches an object given its ID""" 337 | node( 338 | """The ID of an object""" 339 | id: ID! 340 | ): Node 341 | 342 | """Fetches objects given their IDs""" 343 | nodes( 344 | """The IDs of objects""" 345 | ids: [ID!]! 346 | ): [Node]! 347 | } 348 | `); 349 | }); 350 | }); 351 | -------------------------------------------------------------------------------- /src/mutation/__tests__/mutation-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import type { GraphQLFieldConfig } from 'graphql'; 5 | import { 6 | GraphQLInt, 7 | GraphQLObjectType, 8 | GraphQLSchema, 9 | graphql, 10 | graphqlSync, 11 | printType, 12 | printSchema, 13 | } from 'graphql'; 14 | 15 | import { dedent } from '../../__testUtils__/dedent'; 16 | 17 | import { mutationWithClientMutationId } from '../mutation'; 18 | 19 | function dummyResolve() { 20 | return { result: 1 }; 21 | } 22 | 23 | function wrapInSchema(mutationFields: { 24 | [field: string]: GraphQLFieldConfig; 25 | }): GraphQLSchema { 26 | const queryType = new GraphQLObjectType({ 27 | name: 'Query', 28 | fields: { dummy: { type: GraphQLInt } }, 29 | }); 30 | 31 | const mutationType = new GraphQLObjectType({ 32 | name: 'Mutation', 33 | fields: mutationFields, 34 | }); 35 | 36 | return new GraphQLSchema({ 37 | query: queryType, 38 | mutation: mutationType, 39 | }); 40 | } 41 | 42 | describe('mutationWithClientMutationId()', () => { 43 | it('requires an argument', () => { 44 | const someMutation = mutationWithClientMutationId({ 45 | name: 'SomeMutation', 46 | inputFields: {}, 47 | outputFields: { 48 | result: { type: GraphQLInt }, 49 | }, 50 | mutateAndGetPayload: dummyResolve, 51 | }); 52 | 53 | const wrapperType = new GraphQLObjectType({ 54 | name: 'WrapperType', 55 | fields: { someMutation }, 56 | }); 57 | 58 | expect(printType(wrapperType)).to.deep.equal(dedent` 59 | type WrapperType { 60 | someMutation(input: SomeMutationInput!): SomeMutationPayload 61 | } 62 | `); 63 | }); 64 | 65 | it('returns the same client mutation ID', () => { 66 | const someMutation = mutationWithClientMutationId({ 67 | name: 'SomeMutation', 68 | inputFields: {}, 69 | outputFields: { 70 | result: { type: GraphQLInt }, 71 | }, 72 | mutateAndGetPayload: dummyResolve, 73 | }); 74 | const schema = wrapInSchema({ someMutation }); 75 | 76 | const source = ` 77 | mutation { 78 | someMutation(input: {clientMutationId: "abc"}) { 79 | result 80 | clientMutationId 81 | } 82 | } 83 | `; 84 | 85 | expect(graphqlSync({ schema, source })).to.deep.equal({ 86 | data: { 87 | someMutation: { result: 1, clientMutationId: 'abc' }, 88 | }, 89 | }); 90 | }); 91 | 92 | it('supports thunks as input and output fields', () => { 93 | const someMutation = mutationWithClientMutationId({ 94 | name: 'SomeMutation', 95 | inputFields: () => ({ inputData: { type: GraphQLInt } }), 96 | outputFields: () => ({ result: { type: GraphQLInt } }), 97 | mutateAndGetPayload: ({ inputData }) => ({ result: inputData }), 98 | }); 99 | const schema = wrapInSchema({ someMutation }); 100 | 101 | const source = ` 102 | mutation { 103 | someMutation(input: { inputData: 1234, clientMutationId: "abc" }) { 104 | result 105 | clientMutationId 106 | } 107 | } 108 | `; 109 | 110 | expect(graphqlSync({ schema, source })).to.deep.equal({ 111 | data: { 112 | someMutation: { result: 1234, clientMutationId: 'abc' }, 113 | }, 114 | }); 115 | }); 116 | 117 | it('supports promise mutations', async () => { 118 | const someMutation = mutationWithClientMutationId({ 119 | name: 'SomeMutation', 120 | inputFields: {}, 121 | outputFields: { 122 | result: { type: GraphQLInt }, 123 | }, 124 | mutateAndGetPayload: () => Promise.resolve({ result: 1 }), 125 | }); 126 | const schema = wrapInSchema({ someMutation }); 127 | 128 | const source = ` 129 | mutation { 130 | someMutation(input: {clientMutationId: "abc"}) { 131 | result 132 | clientMutationId 133 | } 134 | } 135 | `; 136 | 137 | expect(await graphql({ schema, source })).to.deep.equal({ 138 | data: { 139 | someMutation: { result: 1, clientMutationId: 'abc' }, 140 | }, 141 | }); 142 | }); 143 | 144 | /* FIXME fail because of this https://github.com/graphql/graphql-js/pull/3243#issuecomment-919510590 145 | it.only('JS specific: handles `then` as field name', async () => { 146 | const someMutation = mutationWithClientMutationId({ 147 | name: 'SomeMutation', 148 | inputFields: {}, 149 | outputFields: { 150 | result: { 151 | type: new GraphQLObjectType({ 152 | name: 'Payload', 153 | fields: { 154 | then: { type: GraphQLString }, 155 | }, 156 | }), 157 | }, 158 | }, 159 | mutateAndGetPayload() { 160 | return { 161 | then() { 162 | return new Date(0); 163 | } 164 | }; 165 | }, 166 | }); 167 | const schema = wrapInSchema({ someMutation }); 168 | 169 | const source = ` 170 | mutation { 171 | someMutation(input: {clientMutationId: "abc"}) { 172 | clientMutationId 173 | result { then } 174 | } 175 | } 176 | `; 177 | 178 | expect(await graphql({ schema, source })).to.deep.equal({ 179 | data: { 180 | someMutation: { 181 | clientMutationId: 'abc', 182 | result: { 183 | then: '', 184 | }, 185 | }, 186 | }, 187 | }); 188 | }); 189 | */ 190 | 191 | it('can access rootValue', () => { 192 | const someMutation = mutationWithClientMutationId({ 193 | name: 'SomeMutation', 194 | inputFields: {}, 195 | outputFields: { 196 | result: { type: GraphQLInt }, 197 | }, 198 | mutateAndGetPayload: (_params, _context, { rootValue }) => rootValue, 199 | }); 200 | const schema = wrapInSchema({ someMutation }); 201 | 202 | const source = ` 203 | mutation { 204 | someMutation(input: {clientMutationId: "abc"}) { 205 | result 206 | clientMutationId 207 | } 208 | } 209 | `; 210 | const rootValue = { result: 1 }; 211 | 212 | expect(graphqlSync({ schema, source, rootValue })).to.deep.equal({ 213 | data: { 214 | someMutation: { result: 1, clientMutationId: 'abc' }, 215 | }, 216 | }); 217 | }); 218 | 219 | it('supports mutations returning null', () => { 220 | const someMutation = mutationWithClientMutationId({ 221 | name: 'SomeMutation', 222 | inputFields: {}, 223 | outputFields: { 224 | result: { type: GraphQLInt }, 225 | }, 226 | mutateAndGetPayload: () => null, 227 | }); 228 | const schema = wrapInSchema({ someMutation }); 229 | 230 | const source = ` 231 | mutation { 232 | someMutation(input: {clientMutationId: "abc"}) { 233 | result 234 | clientMutationId 235 | } 236 | } 237 | `; 238 | 239 | expect(graphqlSync({ schema, source })).to.deep.equal({ 240 | data: { someMutation: null }, 241 | }); 242 | }); 243 | 244 | it('supports mutations returning custom classes', () => { 245 | class SomeClass { 246 | getSomeGeneratedData() { 247 | return 1; 248 | } 249 | } 250 | 251 | const someMutation = mutationWithClientMutationId({ 252 | name: 'SomeMutation', 253 | inputFields: {}, 254 | outputFields: { 255 | result: { 256 | type: GraphQLInt, 257 | resolve: (obj) => obj.getSomeGeneratedData(), 258 | }, 259 | }, 260 | mutateAndGetPayload: () => new SomeClass(), 261 | }); 262 | const schema = wrapInSchema({ someMutation }); 263 | 264 | const source = ` 265 | mutation { 266 | someMutation(input: {clientMutationId: "abc"}) { 267 | result 268 | clientMutationId 269 | } 270 | } 271 | `; 272 | 273 | expect(graphqlSync({ schema, source })).to.deep.equal({ 274 | data: { 275 | someMutation: { result: 1, clientMutationId: 'abc' }, 276 | }, 277 | }); 278 | }); 279 | 280 | it('generates correct types', () => { 281 | const description = 'Some Mutation Description'; 282 | const deprecationReason = 'Just because'; 283 | const extensions = Object.freeze({}); 284 | 285 | const someMutationField = mutationWithClientMutationId({ 286 | name: 'SomeMutation', 287 | description, 288 | deprecationReason, 289 | extensions, 290 | inputFields: {}, 291 | outputFields: {}, 292 | mutateAndGetPayload: dummyResolve, 293 | }); 294 | 295 | expect(someMutationField).to.include({ 296 | description, 297 | deprecationReason, 298 | extensions, 299 | }); 300 | }); 301 | 302 | it('generates correct types', () => { 303 | const someMutation = mutationWithClientMutationId({ 304 | name: 'SomeMutation', 305 | inputFields: {}, 306 | outputFields: { 307 | result: { type: GraphQLInt }, 308 | }, 309 | mutateAndGetPayload: dummyResolve, 310 | }); 311 | 312 | const schema = wrapInSchema({ someMutation }); 313 | 314 | expect(printSchema(schema)).to.deep.equal(dedent` 315 | type Query { 316 | dummy: Int 317 | } 318 | 319 | type Mutation { 320 | someMutation(input: SomeMutationInput!): SomeMutationPayload 321 | } 322 | 323 | type SomeMutationPayload { 324 | result: Int 325 | clientMutationId: String 326 | } 327 | 328 | input SomeMutationInput { 329 | clientMutationId: String 330 | } 331 | `); 332 | }); 333 | }); 334 | -------------------------------------------------------------------------------- /resources/gen-changelog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('util'); 4 | const https = require('https'); 5 | 6 | const packageJSON = require('../package.json'); 7 | 8 | const { exec } = require('./utils'); 9 | 10 | const graphqlRequest = util.promisify(graphqlRequestImpl); 11 | const labelsConfig = { 12 | 'PR: breaking change 💥': { 13 | section: 'Breaking Change 💥', 14 | }, 15 | 'PR: feature 🚀': { 16 | section: 'New Feature 🚀', 17 | }, 18 | 'PR: bug fix 🐞': { 19 | section: 'Bug Fix 🐞', 20 | }, 21 | 'PR: docs 📝': { 22 | section: 'Docs 📝', 23 | fold: true, 24 | }, 25 | 'PR: polish 💅': { 26 | section: 'Polish 💅', 27 | fold: true, 28 | }, 29 | 'PR: internal 🏠': { 30 | section: 'Internal 🏠', 31 | fold: true, 32 | }, 33 | 'PR: dependency 📦': { 34 | section: 'Dependency 📦', 35 | fold: true, 36 | }, 37 | }; 38 | const { GH_TOKEN } = process.env; 39 | 40 | if (!GH_TOKEN) { 41 | console.error('Must provide GH_TOKEN as environment variable!'); 42 | process.exit(1); 43 | } 44 | 45 | if (!packageJSON.repository || typeof packageJSON.repository.url !== 'string') { 46 | console.error('package.json is missing repository.url string!'); 47 | process.exit(1); 48 | } 49 | 50 | const repoURLMatch = 51 | /https:\/\/github.com\/(?[^/]+)\/(?[^/]+).git/.exec( 52 | packageJSON.repository.url, 53 | ); 54 | if (repoURLMatch == null) { 55 | console.error('Cannot extract organization and repo name from repo URL!'); 56 | process.exit(1); 57 | } 58 | const { githubOrg, githubRepo } = repoURLMatch.groups; 59 | 60 | getChangeLog() 61 | .then((changelog) => process.stdout.write(changelog)) 62 | .catch((error) => { 63 | console.error(error); 64 | process.exit(1); 65 | }); 66 | 67 | function getChangeLog() { 68 | const { version } = packageJSON; 69 | 70 | let tag = null; 71 | let commitsList = exec(`git rev-list --reverse v${version}..`); 72 | if (commitsList === '') { 73 | const parentPackageJSON = exec('git cat-file blob HEAD~1:package.json'); 74 | const parentVersion = JSON.parse(parentPackageJSON).version; 75 | commitsList = exec(`git rev-list --reverse v${parentVersion}..HEAD~1`); 76 | tag = `v${version}`; 77 | } 78 | 79 | const date = exec('git log -1 --format=%cd --date=short'); 80 | return getCommitsInfo(commitsList.split('\n')) 81 | .then((commitsInfo) => getPRsInfo(commitsInfoToPRs(commitsInfo))) 82 | .then((prsInfo) => genChangeLog(tag, date, prsInfo)); 83 | } 84 | 85 | function genChangeLog(tag, date, allPRs) { 86 | const byLabel = {}; 87 | const committersByLogin = {}; 88 | 89 | for (const pr of allPRs) { 90 | const labels = pr.labels.nodes 91 | .map((label) => label.name) 92 | .filter((label) => label.startsWith('PR: ')); 93 | 94 | if (labels.length === 0) { 95 | throw new Error(`PR is missing label. See ${pr.url}`); 96 | } 97 | if (labels.length > 1) { 98 | throw new Error( 99 | `PR has conflicting labels: ${labels.join('\n')}\nSee ${pr.url}`, 100 | ); 101 | } 102 | 103 | const label = labels[0]; 104 | if (!labelsConfig[label]) { 105 | throw new Error(`Unknown label: ${label}. See ${pr.url}`); 106 | } 107 | byLabel[label] = byLabel[label] || []; 108 | byLabel[label].push(pr); 109 | committersByLogin[pr.author.login] = pr.author; 110 | } 111 | 112 | let changelog = `## ${tag || 'Unreleased'} (${date})\n`; 113 | for (const [label, config] of Object.entries(labelsConfig)) { 114 | const prs = byLabel[label]; 115 | if (prs) { 116 | const shouldFold = config.fold && prs.length > 1; 117 | 118 | changelog += `\n#### ${config.section}\n`; 119 | if (shouldFold) { 120 | changelog += '
\n'; 121 | changelog += ` ${prs.length} PRs were merged \n\n`; 122 | } 123 | 124 | for (const pr of prs) { 125 | const { number, url, author } = pr; 126 | changelog += `* [#${number}](${url}) ${pr.title} ([@${author.login}](${author.url}))\n`; 127 | } 128 | 129 | if (shouldFold) { 130 | changelog += '
\n'; 131 | } 132 | } 133 | } 134 | 135 | const committers = Object.values(committersByLogin).sort((a, b) => 136 | (a.name || a.login).localeCompare(b.name || b.login), 137 | ); 138 | changelog += `\n#### Committers: ${committers.length}\n`; 139 | for (const committer of committers) { 140 | changelog += `* ${committer.name}([@${committer.login}](${committer.url}))\n`; 141 | } 142 | 143 | return changelog; 144 | } 145 | 146 | function graphqlRequestImpl(query, variables, cb) { 147 | const resultCB = typeof variables === 'function' ? variables : cb; 148 | 149 | const req = https.request('https://api.github.com/graphql', { 150 | method: 'POST', 151 | headers: { 152 | Authorization: 'bearer ' + GH_TOKEN, 153 | 'Content-Type': 'application/json', 154 | 'User-Agent': 'gen-changelog', 155 | }, 156 | }); 157 | 158 | req.on('response', (res) => { 159 | let responseBody = ''; 160 | 161 | res.setEncoding('utf8'); 162 | res.on('data', (d) => (responseBody += d)); 163 | res.on('error', (error) => resultCB(error)); 164 | 165 | res.on('end', () => { 166 | if (res.statusCode !== 200) { 167 | return resultCB( 168 | new Error( 169 | `GitHub responded with ${res.statusCode}: ${res.statusMessage}\n` + 170 | responseBody, 171 | ), 172 | ); 173 | } 174 | 175 | let json; 176 | try { 177 | json = JSON.parse(responseBody); 178 | } catch (error) { 179 | return resultCB(error); 180 | } 181 | 182 | if (json.errors) { 183 | return resultCB( 184 | new Error('Errors: ' + JSON.stringify(json.errors, null, 2)), 185 | ); 186 | } 187 | 188 | resultCB(undefined, json.data); 189 | }); 190 | }); 191 | 192 | req.on('error', (error) => resultCB(error)); 193 | req.write(JSON.stringify({ query, variables })); 194 | req.end(); 195 | } 196 | 197 | async function batchCommitInfo(commits) { 198 | let commitsSubQuery = ''; 199 | for (const oid of commits) { 200 | commitsSubQuery += ` 201 | commit_${oid}: object(oid: "${oid}") { 202 | ... on Commit { 203 | oid 204 | message 205 | associatedPullRequests(first: 10) { 206 | nodes { 207 | number 208 | repository { 209 | nameWithOwner 210 | } 211 | } 212 | } 213 | } 214 | } 215 | `; 216 | } 217 | 218 | const response = await graphqlRequest(` 219 | { 220 | repository(owner: "${githubOrg}", name: "${githubRepo}") { 221 | ${commitsSubQuery} 222 | } 223 | } 224 | `); 225 | 226 | const commitsInfo = []; 227 | for (const oid of commits) { 228 | commitsInfo.push(response.repository['commit_' + oid]); 229 | } 230 | return commitsInfo; 231 | } 232 | 233 | async function batchPRInfo(prs) { 234 | let prsSubQuery = ''; 235 | for (const number of prs) { 236 | prsSubQuery += ` 237 | pr_${number}: pullRequest(number: ${number}) { 238 | number 239 | title 240 | url 241 | author { 242 | login 243 | url 244 | ... on User { 245 | name 246 | } 247 | } 248 | labels(first: 10) { 249 | nodes { 250 | name 251 | } 252 | } 253 | } 254 | `; 255 | } 256 | 257 | const response = await graphqlRequest(` 258 | { 259 | repository(owner: "${githubOrg}", name: "${githubRepo}") { 260 | ${prsSubQuery} 261 | } 262 | } 263 | `); 264 | 265 | const prsInfo = []; 266 | for (const number of prs) { 267 | prsInfo.push(response.repository['pr_' + number]); 268 | } 269 | return prsInfo; 270 | } 271 | 272 | function commitsInfoToPRs(commits) { 273 | const prs = {}; 274 | for (const commit of commits) { 275 | const associatedPRs = commit.associatedPullRequests.nodes.filter( 276 | (pr) => pr.repository.nameWithOwner === `${githubOrg}/${githubRepo}`, 277 | ); 278 | if (associatedPRs.length === 0) { 279 | const match = / \(#(?[0-9]+)\)$/m.exec(commit.message); 280 | if (match) { 281 | prs[parseInt(match.groups.prNumber, 10)] = true; 282 | continue; 283 | } 284 | throw new Error( 285 | `Commit ${commit.oid} has no associated PR: ${commit.message}`, 286 | ); 287 | } 288 | if (associatedPRs.length > 1) { 289 | throw new Error( 290 | `Commit ${commit.oid} is associated with multiple PRs: ${commit.message}`, 291 | ); 292 | } 293 | 294 | prs[associatedPRs[0].number] = true; 295 | } 296 | 297 | return Object.keys(prs); 298 | } 299 | 300 | async function getPRsInfo(commits) { 301 | // Split pr into batches of 50 to prevent timeouts 302 | const prInfoPromises = []; 303 | for (let i = 0; i < commits.length; i += 50) { 304 | const batch = commits.slice(i, i + 50); 305 | prInfoPromises.push(batchPRInfo(batch)); 306 | } 307 | 308 | return (await Promise.all(prInfoPromises)).flat(); 309 | } 310 | 311 | async function getCommitsInfo(commits) { 312 | // Split commits into batches of 50 to prevent timeouts 313 | const commitInfoPromises = []; 314 | for (let i = 0; i < commits.length; i += 50) { 315 | const batch = commits.slice(i, i + 50); 316 | commitInfoPromises.push(batchCommitInfo(batch)); 317 | } 318 | 319 | return (await Promise.all(commitInfoPromises)).flat(); 320 | } 321 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Relay Library for GraphQL.js 2 | 3 | This is a library to allow the easy creation of Relay-compliant servers using the [GraphQL.js](https://github.com/graphql/graphql-js) reference implementation of a GraphQL server. 4 | 5 | [![Build Status](https://github.com/graphql/graphql-relay-js/workflows/CI/badge.svg?branch=main)](https://github.com/graphql/graphql-relay-js/actions?query=branch%3Amain) 6 | [![Coverage Status](https://codecov.io/gh/graphql/graphql-relay-js/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql/graphql-relay-js) 7 | 8 | ## Getting Started 9 | 10 | A basic understanding of GraphQL and of the GraphQL.js implementation is needed to provide context for this library. 11 | 12 | An overview of GraphQL in general is available in the [README](https://github.com/graphql/graphql-spec/blob/master/README.md) for the [Specification for GraphQL](https://github.com/graphql/graphql-spec). 13 | 14 | This library is designed to work with the [GraphQL.js](https://github.com/graphql/graphql-js) reference implementation of a GraphQL server. 15 | 16 | An overview of the functionality that a Relay-compliant GraphQL server should provide is in the [GraphQL Relay Specification](https://relay.dev/docs/guides/graphql-server-specification/) on the [Relay website](https://relay.dev/). That overview describes a simple set of examples that exist as [tests](src/__tests__) in this repository. A good way to get started with this repository is to walk through that documentation and the corresponding tests in this library together. 17 | 18 | ## Using Relay Library for GraphQL.js 19 | 20 | Install Relay Library for GraphQL.js 21 | 22 | ```sh 23 | npm install graphql graphql-relay 24 | ``` 25 | 26 | When building a schema for [GraphQL.js](https://github.com/graphql/graphql-js), the provided library functions can be used to simplify the creation of Relay patterns. 27 | 28 | ### Connections 29 | 30 | Helper functions are provided for both building the GraphQL types for connections and for implementing the `resolve` method for fields returning those types. 31 | 32 | - `connectionArgs` returns the arguments that fields should provide when they return a connection type that supports bidirectional pagination. 33 | - `forwardConnectionArgs` returns the arguments that fields should provide when they return a connection type that only supports forward pagination. 34 | - `backwardConnectionArgs` returns the arguments that fields should provide when they return a connection type that only supports backward pagination. 35 | - `connectionDefinitions` returns a `connectionType` and its associated `edgeType`, given a node type. 36 | - `connectionFromArray` is a helper method that takes an array and the arguments from `connectionArgs`, does pagination and filtering, and returns an object in the shape expected by a `connectionType`'s `resolve` function. 37 | - `connectionFromPromisedArray` is similar to `connectionFromArray`, but it takes a promise that resolves to an array, and returns a promise that resolves to the expected shape by `connectionType`. 38 | - `cursorForObjectInConnection` is a helper method that takes an array and a member object, and returns a cursor for use in the mutation payload. 39 | - `offsetToCursor` takes the index of a member object in an array and returns an opaque cursor for use in the mutation payload. 40 | - `cursorToOffset` takes an opaque cursor (created with `offsetToCursor`) and returns the corresponding array index. 41 | 42 | An example usage of these methods from the [test schema](src/__tests__/starWarsSchema.ts): 43 | 44 | ```js 45 | var { connectionType: ShipConnection } = connectionDefinitions({ 46 | nodeType: shipType, 47 | }); 48 | var factionType = new GraphQLObjectType({ 49 | name: 'Faction', 50 | fields: () => ({ 51 | ships: { 52 | type: ShipConnection, 53 | args: connectionArgs, 54 | resolve: (faction, args) => 55 | connectionFromArray( 56 | faction.ships.map((id) => data.Ship[id]), 57 | args, 58 | ), 59 | }, 60 | }), 61 | }); 62 | ``` 63 | 64 | This shows adding a `ships` field to the `Faction` object that is a connection. It uses `connectionDefinitions({nodeType: shipType})` to create the connection type, adds `connectionArgs` as arguments on this function, and then implements the resolve function by passing the array of ships and the arguments to `connectionFromArray`. 65 | 66 | ### Object Identification 67 | 68 | Helper functions are provided for both building the GraphQL types for nodes and for implementing global IDs around local IDs. 69 | 70 | - `nodeDefinitions` returns the `Node` interface that objects can implement, and returns the `node` root field to include on the query type. To implement this, it takes a function to resolve an ID to an object, and to determine the type of a given object. 71 | - `toGlobalId` takes a type name and an ID specific to that type name, and returns a "global ID" that is unique among all types. 72 | - `fromGlobalId` takes the "global ID" created by `toGlobalID`, and returns the type name and ID used to create it. 73 | - `globalIdField` creates the configuration for an `id` field on a node. 74 | - `pluralIdentifyingRootField` creates a field that accepts a list of non-ID identifiers (like a username) and maps them to their corresponding objects. 75 | 76 | An example usage of these methods from the [test schema](src/__tests__/starWarsSchema.ts): 77 | 78 | ```js 79 | var { nodeInterface, nodeField } = nodeDefinitions( 80 | (globalId) => { 81 | var { type, id } = fromGlobalId(globalId); 82 | return data[type][id]; 83 | }, 84 | (obj) => { 85 | return obj.ships ? factionType : shipType; 86 | }, 87 | ); 88 | 89 | var factionType = new GraphQLObjectType({ 90 | name: 'Faction', 91 | fields: () => ({ 92 | id: globalIdField(), 93 | }), 94 | interfaces: [nodeInterface], 95 | }); 96 | 97 | var queryType = new GraphQLObjectType({ 98 | name: 'Query', 99 | fields: () => ({ 100 | node: nodeField, 101 | }), 102 | }); 103 | ``` 104 | 105 | This uses `nodeDefinitions` to construct the `Node` interface and the `node` field; it uses `fromGlobalId` to resolve the IDs passed in the implementation of the function mapping ID to object. It then uses the `globalIdField` method to create the `id` field on `Faction`, which also ensures implements the `nodeInterface`. Finally, it adds the `node` field to the query type, using the `nodeField` returned by `nodeDefinitions`. 106 | 107 | ### Mutations 108 | 109 | A helper function is provided for building mutations with single inputs and client mutation IDs. 110 | 111 | - `mutationWithClientMutationId` takes a name, input fields, output fields, and a mutation method to map from the input fields to the output fields, performing the mutation along the way. It then creates and returns a field configuration that can be used as a top-level field on the mutation type. 112 | 113 | An example usage of these methods from the [test schema](src/__tests__/starWarsSchema.ts): 114 | 115 | ```js 116 | var shipMutation = mutationWithClientMutationId({ 117 | name: 'IntroduceShip', 118 | inputFields: { 119 | shipName: { 120 | type: new GraphQLNonNull(GraphQLString), 121 | }, 122 | factionId: { 123 | type: new GraphQLNonNull(GraphQLID), 124 | }, 125 | }, 126 | outputFields: { 127 | ship: { 128 | type: shipType, 129 | resolve: (payload) => data['Ship'][payload.shipId], 130 | }, 131 | faction: { 132 | type: factionType, 133 | resolve: (payload) => data['Faction'][payload.factionId], 134 | }, 135 | }, 136 | mutateAndGetPayload: ({ shipName, factionId }) => { 137 | var newShip = { 138 | id: getNewShipId(), 139 | name: shipName, 140 | }; 141 | data.Ship[newShip.id] = newShip; 142 | data.Faction[factionId].ships.push(newShip.id); 143 | return { 144 | shipId: newShip.id, 145 | factionId: factionId, 146 | }; 147 | }, 148 | }); 149 | 150 | var mutationType = new GraphQLObjectType({ 151 | name: 'Mutation', 152 | fields: () => ({ 153 | introduceShip: shipMutation, 154 | }), 155 | }); 156 | ``` 157 | 158 | This code creates a mutation named `IntroduceShip`, which takes a faction ID and a ship name as input. It outputs the `Faction` and the `Ship` in question. `mutateAndGetPayload` then gets an object with a property for each input field, performs the mutation by constructing the new ship, then returns an object that will be resolved by the output fields. 159 | 160 | Our mutation type then creates the `introduceShip` field using the return value of `mutationWithClientMutationId`. 161 | 162 | ## Contributing 163 | 164 | After cloning this repo, ensure dependencies are installed by running: 165 | 166 | ```sh 167 | npm install 168 | ``` 169 | 170 | This library is written in ES6 and uses [Babel](https://babeljs.io/) for ES5 transpilation and [TypeScript](https://www.typescriptlang.org/) for type safety. Widely consumable JavaScript can be produced by running: 171 | 172 | ```sh 173 | npm run build 174 | ``` 175 | 176 | Once `npm run build` has run, you may `import` or `require()` directly from node. 177 | 178 | After developing, the full test suite can be evaluated by running: 179 | 180 | ```sh 181 | npm test 182 | ``` 183 | 184 | ## Opening a PR 185 | 186 | We actively welcome pull requests. Learn how to [contribute](./.github/CONTRIBUTING.md). 187 | 188 | This repository is managed by EasyCLA. Project participants must sign the free ([GraphQL Specification Membership agreement](https://preview-spec-membership.graphql.org) before making a contribution. You only need to do this one time, and it can be signed by [individual contributors](https://individual-spec-membership.graphql.org/) or their [employers](https://corporate-spec-membership.graphql.org/). 189 | 190 | To initiate the signature process please open a PR against this repo. The EasyCLA bot will block the merge if we still need a membership agreement from you. 191 | 192 | You can find [detailed information here](https://github.com/graphql/graphql-wg/tree/main/membership). If you have issues, please email [operations@graphql.org](mailto:operations@graphql.org). 193 | 194 | If your company benefits from GraphQL and you would like to provide essential financial support for the systems and people that power our community, please also consider membership in the [GraphQL Foundation](https://foundation.graphql.org/join). 195 | 196 | ## Changelog 197 | 198 | Changes are tracked as [GitHub releases](https://github.com/graphql/graphql-js/releases). 199 | 200 | ## License 201 | 202 | graphql-relay-js is [MIT licensed](./LICENSE). 203 | -------------------------------------------------------------------------------- /src/connection/__tests__/arrayConnection-test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { expect } from 'chai'; 3 | 4 | import { 5 | offsetToCursor, 6 | connectionFromArray, 7 | connectionFromArraySlice, 8 | connectionFromPromisedArray, 9 | connectionFromPromisedArraySlice, 10 | cursorForObjectInConnection, 11 | } from '../arrayConnection'; 12 | 13 | const arrayABCDE = ['A', 'B', 'C', 'D', 'E']; 14 | 15 | const cursorA = 'YXJyYXljb25uZWN0aW9uOjA='; 16 | const cursorB = 'YXJyYXljb25uZWN0aW9uOjE='; 17 | const cursorC = 'YXJyYXljb25uZWN0aW9uOjI='; 18 | const cursorD = 'YXJyYXljb25uZWN0aW9uOjM='; 19 | const cursorE = 'YXJyYXljb25uZWN0aW9uOjQ='; 20 | 21 | const edgeA = { node: 'A', cursor: cursorA }; 22 | const edgeB = { node: 'B', cursor: cursorB }; 23 | const edgeC = { node: 'C', cursor: cursorC }; 24 | const edgeD = { node: 'D', cursor: cursorD }; 25 | const edgeE = { node: 'E', cursor: cursorE }; 26 | 27 | describe('connectionFromArray()', () => { 28 | describe('basic slicing', () => { 29 | it('returns all elements without filters', () => { 30 | const c = connectionFromArray(arrayABCDE, {}); 31 | expect(c).to.deep.equal({ 32 | edges: [edgeA, edgeB, edgeC, edgeD, edgeE], 33 | pageInfo: { 34 | startCursor: cursorA, 35 | endCursor: cursorE, 36 | hasPreviousPage: false, 37 | hasNextPage: false, 38 | }, 39 | }); 40 | }); 41 | 42 | it('respects a smaller first', () => { 43 | const c = connectionFromArray(arrayABCDE, { first: 2 }); 44 | expect(c).to.deep.equal({ 45 | edges: [edgeA, edgeB], 46 | pageInfo: { 47 | startCursor: cursorA, 48 | endCursor: cursorB, 49 | hasPreviousPage: false, 50 | hasNextPage: true, 51 | }, 52 | }); 53 | }); 54 | 55 | it('respects an overly large first', () => { 56 | const c = connectionFromArray(arrayABCDE, { first: 10 }); 57 | expect(c).to.deep.equal({ 58 | edges: [edgeA, edgeB, edgeC, edgeD, edgeE], 59 | pageInfo: { 60 | startCursor: cursorA, 61 | endCursor: cursorE, 62 | hasPreviousPage: false, 63 | hasNextPage: false, 64 | }, 65 | }); 66 | }); 67 | 68 | it('respects a smaller last', () => { 69 | const c = connectionFromArray(arrayABCDE, { last: 2 }); 70 | expect(c).to.deep.equal({ 71 | edges: [edgeD, edgeE], 72 | pageInfo: { 73 | startCursor: cursorD, 74 | endCursor: cursorE, 75 | hasPreviousPage: true, 76 | hasNextPage: false, 77 | }, 78 | }); 79 | }); 80 | 81 | it('respects an overly large last', () => { 82 | const c = connectionFromArray(arrayABCDE, { last: 10 }); 83 | expect(c).to.deep.equal({ 84 | edges: [edgeA, edgeB, edgeC, edgeD, edgeE], 85 | pageInfo: { 86 | startCursor: cursorA, 87 | endCursor: cursorE, 88 | hasPreviousPage: false, 89 | hasNextPage: false, 90 | }, 91 | }); 92 | }); 93 | }); 94 | 95 | describe('pagination', () => { 96 | it('respects first and after', () => { 97 | const c = connectionFromArray(arrayABCDE, { 98 | first: 2, 99 | after: cursorB, 100 | }); 101 | expect(c).to.deep.equal({ 102 | edges: [edgeC, edgeD], 103 | pageInfo: { 104 | startCursor: cursorC, 105 | endCursor: cursorD, 106 | hasPreviousPage: false, 107 | hasNextPage: true, 108 | }, 109 | }); 110 | }); 111 | 112 | it('respects first and after with long first', () => { 113 | const c = connectionFromArray(arrayABCDE, { 114 | first: 10, 115 | after: cursorB, 116 | }); 117 | expect(c).to.deep.equal({ 118 | edges: [edgeC, edgeD, edgeE], 119 | pageInfo: { 120 | startCursor: cursorC, 121 | endCursor: cursorE, 122 | hasPreviousPage: false, 123 | hasNextPage: false, 124 | }, 125 | }); 126 | }); 127 | 128 | it('respects last and before', () => { 129 | const c = connectionFromArray(arrayABCDE, { 130 | last: 2, 131 | before: cursorD, 132 | }); 133 | expect(c).to.deep.equal({ 134 | edges: [edgeB, edgeC], 135 | pageInfo: { 136 | startCursor: cursorB, 137 | endCursor: cursorC, 138 | hasPreviousPage: true, 139 | hasNextPage: false, 140 | }, 141 | }); 142 | }); 143 | 144 | it('respects last and before with long last', () => { 145 | const c = connectionFromArray(arrayABCDE, { 146 | last: 10, 147 | before: cursorD, 148 | }); 149 | expect(c).to.deep.equal({ 150 | edges: [edgeA, edgeB, edgeC], 151 | pageInfo: { 152 | startCursor: cursorA, 153 | endCursor: cursorC, 154 | hasPreviousPage: false, 155 | hasNextPage: false, 156 | }, 157 | }); 158 | }); 159 | 160 | it('respects first and after and before, too few', () => { 161 | const c = connectionFromArray(arrayABCDE, { 162 | first: 2, 163 | after: cursorA, 164 | before: cursorE, 165 | }); 166 | expect(c).to.deep.equal({ 167 | edges: [edgeB, edgeC], 168 | pageInfo: { 169 | startCursor: cursorB, 170 | endCursor: cursorC, 171 | hasPreviousPage: false, 172 | hasNextPage: true, 173 | }, 174 | }); 175 | }); 176 | 177 | it('respects first and after and before, too many', () => { 178 | const c = connectionFromArray(arrayABCDE, { 179 | first: 4, 180 | after: cursorA, 181 | before: cursorE, 182 | }); 183 | expect(c).to.deep.equal({ 184 | edges: [edgeB, edgeC, edgeD], 185 | pageInfo: { 186 | startCursor: cursorB, 187 | endCursor: cursorD, 188 | hasPreviousPage: false, 189 | hasNextPage: false, 190 | }, 191 | }); 192 | }); 193 | 194 | it('respects first and after and before, exactly right', () => { 195 | const c = connectionFromArray(arrayABCDE, { 196 | first: 3, 197 | after: cursorA, 198 | before: cursorE, 199 | }); 200 | expect(c).to.deep.equal({ 201 | edges: [edgeB, edgeC, edgeD], 202 | pageInfo: { 203 | startCursor: cursorB, 204 | endCursor: cursorD, 205 | hasPreviousPage: false, 206 | hasNextPage: false, 207 | }, 208 | }); 209 | }); 210 | 211 | it('respects last and after and before, too few', () => { 212 | const c = connectionFromArray(arrayABCDE, { 213 | last: 2, 214 | after: cursorA, 215 | before: cursorE, 216 | }); 217 | expect(c).to.deep.equal({ 218 | edges: [edgeC, edgeD], 219 | pageInfo: { 220 | startCursor: cursorC, 221 | endCursor: cursorD, 222 | hasPreviousPage: true, 223 | hasNextPage: false, 224 | }, 225 | }); 226 | }); 227 | 228 | it('respects last and after and before, too many', () => { 229 | const c = connectionFromArray(arrayABCDE, { 230 | last: 4, 231 | after: cursorA, 232 | before: cursorE, 233 | }); 234 | expect(c).to.deep.equal({ 235 | edges: [edgeB, edgeC, edgeD], 236 | pageInfo: { 237 | startCursor: cursorB, 238 | endCursor: cursorD, 239 | hasPreviousPage: false, 240 | hasNextPage: false, 241 | }, 242 | }); 243 | }); 244 | 245 | it('respects last and after and before, exactly right', () => { 246 | const c = connectionFromArray(arrayABCDE, { 247 | last: 3, 248 | after: cursorA, 249 | before: cursorE, 250 | }); 251 | expect(c).to.deep.equal({ 252 | edges: [edgeB, edgeC, edgeD], 253 | pageInfo: { 254 | startCursor: cursorB, 255 | endCursor: cursorD, 256 | hasPreviousPage: false, 257 | hasNextPage: false, 258 | }, 259 | }); 260 | }); 261 | }); 262 | 263 | describe('cursor edge cases', () => { 264 | it('throws an error if first < 0', () => { 265 | expect(() => { 266 | connectionFromArray(arrayABCDE, { first: -1 }); 267 | }).to.throw('Argument "first" must be a non-negative integer'); 268 | }); 269 | 270 | it('throws an error if last < 0', () => { 271 | expect(() => { 272 | connectionFromArray(arrayABCDE, { last: -1 }); 273 | }).to.throw('Argument "last" must be a non-negative integer'); 274 | }); 275 | 276 | it('returns all elements if cursors are invalid', () => { 277 | const c1 = connectionFromArray(arrayABCDE, { 278 | before: 'InvalidBase64', 279 | after: 'InvalidBase64', 280 | }); 281 | 282 | const invalidUnicodeInBase64 = '9JCAgA=='; // U+110000 283 | const c2 = connectionFromArray(arrayABCDE, { 284 | before: invalidUnicodeInBase64, 285 | after: invalidUnicodeInBase64, 286 | }); 287 | 288 | expect(c1).to.deep.equal(c2); 289 | expect(c1).to.deep.equal({ 290 | edges: [edgeA, edgeB, edgeC, edgeD, edgeE], 291 | pageInfo: { 292 | startCursor: cursorA, 293 | endCursor: cursorE, 294 | hasPreviousPage: false, 295 | hasNextPage: false, 296 | }, 297 | }); 298 | }); 299 | 300 | it('returns all elements if cursors are on the outside', () => { 301 | const allEdges = { 302 | edges: [edgeA, edgeB, edgeC, edgeD, edgeE], 303 | pageInfo: { 304 | startCursor: cursorA, 305 | endCursor: cursorE, 306 | hasPreviousPage: false, 307 | hasNextPage: false, 308 | }, 309 | }; 310 | 311 | expect( 312 | connectionFromArray(arrayABCDE, { before: offsetToCursor(6) }), 313 | ).to.deep.equal(allEdges); 314 | expect( 315 | connectionFromArray(arrayABCDE, { before: offsetToCursor(-1) }), 316 | ).to.deep.equal(allEdges); 317 | 318 | expect( 319 | connectionFromArray(arrayABCDE, { after: offsetToCursor(6) }), 320 | ).to.deep.equal(allEdges); 321 | expect( 322 | connectionFromArray(arrayABCDE, { after: offsetToCursor(-1) }), 323 | ).to.deep.equal(allEdges); 324 | }); 325 | 326 | it('returns no elements if cursors cross', () => { 327 | const c = connectionFromArray(arrayABCDE, { 328 | before: cursorC, 329 | after: cursorE, 330 | }); 331 | expect(c).to.deep.equal({ 332 | edges: [], 333 | pageInfo: { 334 | startCursor: null, 335 | endCursor: null, 336 | hasPreviousPage: false, 337 | hasNextPage: false, 338 | }, 339 | }); 340 | }); 341 | }); 342 | 343 | describe('cursorForObjectInConnection()', () => { 344 | it("returns an edge's cursor, given an array and a member object", () => { 345 | const letterBCursor = cursorForObjectInConnection(arrayABCDE, 'B'); 346 | expect(letterBCursor).to.equal(cursorB); 347 | }); 348 | 349 | it('returns null, given an array and a non-member object', () => { 350 | const letterFCursor = cursorForObjectInConnection(arrayABCDE, 'F'); 351 | expect(letterFCursor).to.equal(null); 352 | }); 353 | }); 354 | }); 355 | 356 | describe('connectionFromPromisedArray()', () => { 357 | it('returns all elements without filters', async () => { 358 | const c = await connectionFromPromisedArray( 359 | Promise.resolve(arrayABCDE), 360 | {}, 361 | ); 362 | expect(c).to.deep.equal({ 363 | edges: [edgeA, edgeB, edgeC, edgeD, edgeE], 364 | pageInfo: { 365 | startCursor: cursorA, 366 | endCursor: cursorE, 367 | hasPreviousPage: false, 368 | hasNextPage: false, 369 | }, 370 | }); 371 | }); 372 | 373 | it('respects a smaller first', async () => { 374 | const c = await connectionFromPromisedArray(Promise.resolve(arrayABCDE), { 375 | first: 2, 376 | }); 377 | expect(c).to.deep.equal({ 378 | edges: [edgeA, edgeB], 379 | pageInfo: { 380 | startCursor: cursorA, 381 | endCursor: cursorB, 382 | hasPreviousPage: false, 383 | hasNextPage: true, 384 | }, 385 | }); 386 | }); 387 | }); 388 | 389 | describe('connectionFromArraySlice()', () => { 390 | it('works with a just-right array slice', () => { 391 | const c = connectionFromArraySlice( 392 | arrayABCDE.slice(1, 3), 393 | { 394 | first: 2, 395 | after: cursorA, 396 | }, 397 | { 398 | sliceStart: 1, 399 | arrayLength: 5, 400 | }, 401 | ); 402 | expect(c).to.deep.equal({ 403 | edges: [edgeB, edgeC], 404 | pageInfo: { 405 | startCursor: cursorB, 406 | endCursor: cursorC, 407 | hasPreviousPage: false, 408 | hasNextPage: true, 409 | }, 410 | }); 411 | }); 412 | 413 | it('works with an oversized array slice ("left" side)', () => { 414 | const c = connectionFromArraySlice( 415 | arrayABCDE.slice(0, 3), 416 | { 417 | first: 2, 418 | after: cursorA, 419 | }, 420 | { 421 | sliceStart: 0, 422 | arrayLength: 5, 423 | }, 424 | ); 425 | expect(c).to.deep.equal({ 426 | edges: [edgeB, edgeC], 427 | pageInfo: { 428 | startCursor: cursorB, 429 | endCursor: cursorC, 430 | hasPreviousPage: false, 431 | hasNextPage: true, 432 | }, 433 | }); 434 | }); 435 | 436 | it('works with an oversized array slice ("right" side)', () => { 437 | const c = connectionFromArraySlice( 438 | arrayABCDE.slice(2, 4), 439 | { 440 | first: 1, 441 | after: cursorB, 442 | }, 443 | { 444 | sliceStart: 2, 445 | arrayLength: 5, 446 | }, 447 | ); 448 | expect(c).to.deep.equal({ 449 | edges: [edgeC], 450 | pageInfo: { 451 | startCursor: cursorC, 452 | endCursor: cursorC, 453 | hasPreviousPage: false, 454 | hasNextPage: true, 455 | }, 456 | }); 457 | }); 458 | 459 | it('works with an oversized array slice (both sides)', () => { 460 | const c = connectionFromArraySlice( 461 | arrayABCDE.slice(1, 4), 462 | { 463 | first: 1, 464 | after: cursorB, 465 | }, 466 | { 467 | sliceStart: 1, 468 | arrayLength: 5, 469 | }, 470 | ); 471 | expect(c).to.deep.equal({ 472 | edges: [edgeC], 473 | pageInfo: { 474 | startCursor: cursorC, 475 | endCursor: cursorC, 476 | hasPreviousPage: false, 477 | hasNextPage: true, 478 | }, 479 | }); 480 | }); 481 | 482 | it('works with an undersized array slice ("left" side)', () => { 483 | const c = connectionFromArraySlice( 484 | arrayABCDE.slice(3, 5), 485 | { 486 | first: 3, 487 | after: cursorB, 488 | }, 489 | { 490 | sliceStart: 3, 491 | arrayLength: 5, 492 | }, 493 | ); 494 | expect(c).to.deep.equal({ 495 | edges: [edgeD, edgeE], 496 | pageInfo: { 497 | startCursor: cursorD, 498 | endCursor: cursorE, 499 | hasPreviousPage: false, 500 | hasNextPage: false, 501 | }, 502 | }); 503 | }); 504 | 505 | it('works with an undersized array slice ("right" side)', () => { 506 | const c = connectionFromArraySlice( 507 | arrayABCDE.slice(2, 4), 508 | { 509 | first: 3, 510 | after: cursorB, 511 | }, 512 | { 513 | sliceStart: 2, 514 | arrayLength: 5, 515 | }, 516 | ); 517 | expect(c).to.deep.equal({ 518 | edges: [edgeC, edgeD], 519 | pageInfo: { 520 | startCursor: cursorC, 521 | endCursor: cursorD, 522 | hasPreviousPage: false, 523 | hasNextPage: true, 524 | }, 525 | }); 526 | }); 527 | 528 | it('works with an undersized array slice (both sides)', () => { 529 | const c = connectionFromArraySlice( 530 | arrayABCDE.slice(3, 4), 531 | { 532 | first: 3, 533 | after: cursorB, 534 | }, 535 | { 536 | sliceStart: 3, 537 | arrayLength: 5, 538 | }, 539 | ); 540 | expect(c).to.deep.equal({ 541 | edges: [edgeD], 542 | pageInfo: { 543 | startCursor: cursorD, 544 | endCursor: cursorD, 545 | hasPreviousPage: false, 546 | hasNextPage: true, 547 | }, 548 | }); 549 | }); 550 | }); 551 | 552 | describe('connectionFromPromisedArraySlice()', () => { 553 | it('respects a smaller first', async () => { 554 | const c = await connectionFromPromisedArraySlice( 555 | Promise.resolve(arrayABCDE), 556 | { first: 2 }, 557 | { 558 | sliceStart: 0, 559 | arrayLength: 5, 560 | }, 561 | ); 562 | expect(c).to.deep.equal({ 563 | edges: [edgeA, edgeB], 564 | pageInfo: { 565 | startCursor: cursorA, 566 | endCursor: cursorB, 567 | hasPreviousPage: false, 568 | hasNextPage: true, 569 | }, 570 | }); 571 | }); 572 | }); 573 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: script 3 | ecmaVersion: 2020 4 | env: 5 | es6: true 6 | reportUnusedDisableDirectives: true 7 | plugins: 8 | - node 9 | - istanbul 10 | - import 11 | settings: 12 | node: 13 | tryExtensions: ['.js', '.json', '.node', '.ts', '.d.ts'] 14 | 15 | rules: 16 | ############################################################################## 17 | # `eslint-plugin-istanbul` rule list based on `v0.1.2` 18 | # https://github.com/istanbuljs/eslint-plugin-istanbul#rules 19 | ############################################################################## 20 | 21 | istanbul/no-ignore-file: error 22 | istanbul/prefer-ignore-reason: error 23 | 24 | ############################################################################## 25 | # `eslint-plugin-node` rule list based on `v11.1.x` 26 | ############################################################################## 27 | 28 | # Possible Errors 29 | # https://github.com/mysticatea/eslint-plugin-node#possible-errors 30 | 31 | node/handle-callback-err: [error, error] 32 | node/no-callback-literal: error 33 | node/no-exports-assign: error 34 | node/no-extraneous-import: error 35 | node/no-extraneous-require: error 36 | node/no-missing-import: error 37 | node/no-missing-require: error 38 | node/no-new-require: error 39 | node/no-path-concat: error 40 | node/no-process-exit: off 41 | node/no-unpublished-bin: error 42 | node/no-unpublished-import: error 43 | node/no-unpublished-require: error 44 | node/no-unsupported-features/es-builtins: error 45 | node/no-unsupported-features/es-syntax: [error, { ignores: [modules] }] 46 | node/no-unsupported-features/node-builtins: error 47 | node/process-exit-as-throw: error 48 | node/shebang: error 49 | 50 | # Best Practices 51 | # https://github.com/mysticatea/eslint-plugin-node#best-practices 52 | node/no-deprecated-api: error 53 | 54 | # Stylistic Issues 55 | # https://github.com/mysticatea/eslint-plugin-node#stylistic-issues 56 | 57 | node/callback-return: error 58 | node/exports-style: off # TODO consider 59 | node/file-extension-in-import: off # TODO consider 60 | node/global-require: error 61 | node/no-mixed-requires: error 62 | node/no-process-env: off 63 | node/no-restricted-import: off 64 | node/no-restricted-require: off 65 | node/no-sync: error 66 | node/prefer-global/buffer: error 67 | node/prefer-global/console: error 68 | node/prefer-global/process: error 69 | node/prefer-global/text-decoder: error 70 | node/prefer-global/text-encoder: error 71 | node/prefer-global/url-search-params: error 72 | node/prefer-global/url: error 73 | node/prefer-promises/dns: off 74 | node/prefer-promises/fs: off 75 | 76 | ############################################################################## 77 | # `eslint-plugin-import` rule list based on `v2.23.x` 78 | ############################################################################## 79 | 80 | # Static analysis 81 | # https://github.com/benmosher/eslint-plugin-import#static-analysis 82 | import/no-unresolved: error 83 | import/named: error 84 | import/default: error 85 | import/namespace: error 86 | import/no-restricted-paths: off 87 | import/no-absolute-path: error 88 | import/no-dynamic-require: error 89 | import/no-internal-modules: off 90 | import/no-webpack-loader-syntax: error 91 | import/no-self-import: error 92 | import/no-cycle: error 93 | import/no-useless-path-segments: error 94 | import/no-relative-parent-imports: off 95 | import/no-relative-packages: off 96 | 97 | # Helpful warnings 98 | # https://github.com/benmosher/eslint-plugin-import#helpful-warnings 99 | import/export: error 100 | import/no-named-as-default: error 101 | import/no-named-as-default-member: error 102 | import/no-deprecated: error 103 | import/no-extraneous-dependencies: [error, { devDependencies: false }] 104 | import/no-mutable-exports: error 105 | import/no-unused-modules: error 106 | 107 | # Module systems 108 | # https://github.com/benmosher/eslint-plugin-import#module-systems 109 | import/unambiguous: error 110 | import/no-commonjs: error 111 | import/no-amd: error 112 | import/no-nodejs-modules: error 113 | import/no-import-module-exports: off 114 | 115 | # Style guide 116 | # https://github.com/benmosher/eslint-plugin-import#style-guide 117 | import/first: error 118 | import/exports-last: off 119 | import/no-duplicates: error 120 | import/no-namespace: error 121 | import/extensions: 122 | - error 123 | - ignorePackages 124 | - ts: never # TODO: remove once TS supports extensions 125 | js: never # TODO: remove 126 | import/order: [error, { newlines-between: always-and-inside-groups }] 127 | import/newline-after-import: error 128 | import/prefer-default-export: off 129 | import/max-dependencies: off 130 | import/no-unassigned-import: error 131 | import/no-named-default: error 132 | import/no-default-export: error 133 | import/no-named-export: off 134 | import/no-anonymous-default-export: error 135 | import/group-exports: off 136 | import/dynamic-import-chunkname: off 137 | 138 | ############################################################################## 139 | # ESLint builtin rules list based on `v7.29.x` 140 | ############################################################################## 141 | 142 | # Possible Errors 143 | # https://eslint.org/docs/rules/#possible-errors 144 | 145 | for-direction: error 146 | getter-return: error 147 | no-async-promise-executor: error 148 | no-await-in-loop: error 149 | no-compare-neg-zero: error 150 | no-cond-assign: error 151 | no-console: warn 152 | no-constant-condition: error 153 | no-control-regex: error 154 | no-debugger: warn 155 | no-dupe-args: error 156 | no-dupe-else-if: error 157 | no-dupe-keys: error 158 | no-duplicate-case: error 159 | no-empty: error 160 | no-empty-character-class: error 161 | no-ex-assign: error 162 | no-extra-boolean-cast: error 163 | no-func-assign: error 164 | no-import-assign: error 165 | no-inner-declarations: [error, both] 166 | no-invalid-regexp: error 167 | no-irregular-whitespace: error 168 | no-loss-of-precision: error 169 | no-misleading-character-class: error 170 | no-obj-calls: error 171 | no-promise-executor-return: off # TODO 172 | no-prototype-builtins: error 173 | no-regex-spaces: error 174 | no-setter-return: error 175 | no-sparse-arrays: error 176 | no-template-curly-in-string: error 177 | no-unreachable: error 178 | no-unreachable-loop: error 179 | no-unsafe-finally: error 180 | no-unsafe-negation: error 181 | no-unsafe-optional-chaining: [error, { disallowArithmeticOperators: true }] 182 | no-useless-backreference: error 183 | require-atomic-updates: error 184 | use-isnan: error 185 | valid-typeof: error 186 | 187 | # Best Practices 188 | # https://eslint.org/docs/rules/#best-practices 189 | 190 | accessor-pairs: error 191 | array-callback-return: error 192 | block-scoped-var: error 193 | class-methods-use-this: off 194 | complexity: off 195 | consistent-return: off 196 | curly: error 197 | default-case: off 198 | default-case-last: error 199 | default-param-last: error 200 | dot-notation: error 201 | eqeqeq: [error, smart] 202 | grouped-accessor-pairs: error 203 | guard-for-in: error 204 | max-classes-per-file: off 205 | no-alert: error 206 | no-caller: error 207 | no-case-declarations: error 208 | no-constructor-return: error 209 | no-div-regex: error 210 | no-else-return: error 211 | no-empty-function: error 212 | no-empty-pattern: error 213 | no-eq-null: off 214 | no-eval: error 215 | no-extend-native: error 216 | no-extra-bind: error 217 | no-extra-label: error 218 | no-fallthrough: error 219 | no-global-assign: error 220 | no-implicit-coercion: error 221 | no-implicit-globals: off 222 | no-implied-eval: error 223 | no-invalid-this: error 224 | no-iterator: error 225 | no-labels: error 226 | no-lone-blocks: error 227 | no-loop-func: error 228 | no-magic-numbers: off 229 | no-multi-str: error 230 | no-new: error 231 | no-new-func: error 232 | no-new-wrappers: error 233 | no-nonoctal-decimal-escape: error 234 | no-octal: error 235 | no-octal-escape: error 236 | no-param-reassign: error 237 | no-proto: error 238 | no-redeclare: error 239 | no-restricted-properties: off 240 | no-return-assign: error 241 | no-return-await: error 242 | no-script-url: error 243 | no-self-assign: error 244 | no-self-compare: error 245 | no-sequences: error 246 | no-throw-literal: error 247 | no-unmodified-loop-condition: error 248 | no-unused-expressions: error 249 | no-unused-labels: error 250 | no-useless-call: error 251 | no-useless-catch: error 252 | no-useless-concat: error 253 | no-useless-escape: error 254 | no-useless-return: error 255 | no-void: error 256 | no-warning-comments: off 257 | no-with: error 258 | prefer-named-capture-group: off # ES2018 259 | prefer-promise-reject-errors: error 260 | prefer-regex-literals: error 261 | radix: error 262 | require-await: error 263 | require-unicode-regexp: off 264 | vars-on-top: error 265 | yoda: [error, never, { exceptRange: true }] 266 | 267 | # Strict Mode 268 | # https://eslint.org/docs/rules/#strict-mode 269 | 270 | strict: error 271 | 272 | # Variables 273 | # https://eslint.org/docs/rules/#variables 274 | 275 | init-declarations: off 276 | no-delete-var: error 277 | no-label-var: error 278 | no-restricted-globals: off 279 | no-shadow: error 280 | no-shadow-restricted-names: error 281 | no-undef: error 282 | no-undef-init: error 283 | no-undefined: off 284 | no-unused-vars: [error, { vars: all, args: all, argsIgnorePattern: '^_' }] 285 | no-use-before-define: off 286 | 287 | # Stylistic Issues 288 | # https://eslint.org/docs/rules/#stylistic-issues 289 | 290 | camelcase: error 291 | capitalized-comments: off # maybe 292 | consistent-this: off 293 | func-name-matching: off 294 | func-names: [error, as-needed] # improve debug experience 295 | func-style: off 296 | id-denylist: off 297 | id-length: off 298 | id-match: [error, '^(?:_?[a-zA-Z0-9]*)|[_A-Z0-9]+$'] 299 | line-comment-position: off 300 | lines-around-comment: off 301 | lines-between-class-members: [error, always, { exceptAfterSingleLine: true }] 302 | max-depth: off 303 | max-lines: off 304 | max-lines-per-function: off 305 | max-nested-callbacks: off 306 | max-params: off 307 | max-statements: off 308 | max-statements-per-line: off 309 | multiline-comment-style: off 310 | new-cap: error 311 | no-array-constructor: error 312 | no-bitwise: off 313 | no-continue: off 314 | no-inline-comments: off 315 | no-lonely-if: error 316 | no-multi-assign: off 317 | no-negated-condition: off 318 | no-nested-ternary: off 319 | no-new-object: error 320 | no-plusplus: off 321 | no-restricted-syntax: off 322 | no-tabs: error 323 | no-ternary: off 324 | no-underscore-dangle: error 325 | no-unneeded-ternary: error 326 | one-var: [error, never] 327 | operator-assignment: error 328 | padding-line-between-statements: off 329 | prefer-exponentiation-operator: error 330 | prefer-object-spread: error 331 | quotes: [error, single, { avoidEscape: true }] 332 | sort-keys: off 333 | sort-vars: off 334 | spaced-comment: error 335 | 336 | # ECMAScript 6 337 | # https://eslint.org/docs/rules/#ecmascript-6 338 | 339 | arrow-body-style: error 340 | constructor-super: error 341 | no-class-assign: error 342 | no-const-assign: error 343 | no-dupe-class-members: error 344 | no-duplicate-imports: off # Superseded by `import/no-duplicates` 345 | no-new-symbol: error 346 | no-restricted-exports: off 347 | no-restricted-imports: off 348 | no-this-before-super: error 349 | no-useless-computed-key: error 350 | no-useless-constructor: error 351 | no-useless-rename: error 352 | no-var: error 353 | object-shorthand: error 354 | prefer-arrow-callback: error 355 | prefer-const: error 356 | prefer-destructuring: off 357 | prefer-numeric-literals: error 358 | prefer-rest-params: error 359 | prefer-spread: error 360 | prefer-template: off 361 | require-yield: error 362 | sort-imports: off 363 | symbol-description: off 364 | 365 | # Bellow rules are disabled because coflicts with Prettier, see: 366 | # https://github.com/prettier/eslint-config-prettier/blob/master/index.js 367 | array-bracket-newline: off 368 | array-bracket-spacing: off 369 | array-element-newline: off 370 | arrow-parens: off 371 | arrow-spacing: off 372 | block-spacing: off 373 | brace-style: off 374 | comma-dangle: off 375 | comma-spacing: off 376 | comma-style: off 377 | computed-property-spacing: off 378 | dot-location: off 379 | eol-last: off 380 | func-call-spacing: off 381 | function-call-argument-newline: off 382 | function-paren-newline: off 383 | generator-star-spacing: off 384 | implicit-arrow-linebreak: off 385 | indent: off 386 | jsx-quotes: off 387 | key-spacing: off 388 | keyword-spacing: off 389 | linebreak-style: off 390 | max-len: off 391 | multiline-ternary: off 392 | newline-per-chained-call: off 393 | new-parens: off 394 | no-confusing-arrow: off 395 | no-extra-parens: off 396 | no-extra-semi: off 397 | no-floating-decimal: off 398 | no-mixed-operators: off 399 | no-mixed-spaces-and-tabs: off 400 | no-multi-spaces: off 401 | no-multiple-empty-lines: off 402 | no-trailing-spaces: off 403 | no-unexpected-multiline: off 404 | no-whitespace-before-property: off 405 | nonblock-statement-body-position: off 406 | object-curly-newline: off 407 | object-curly-spacing: off 408 | object-property-newline: off 409 | one-var-declaration-per-line: off 410 | operator-linebreak: off 411 | padded-blocks: off 412 | quote-props: off 413 | rest-spread-spacing: off 414 | semi: off 415 | semi-spacing: off 416 | semi-style: off 417 | space-before-blocks: off 418 | space-before-function-paren: off 419 | space-in-parens: off 420 | space-infix-ops: off 421 | space-unary-ops: off 422 | switch-colon-spacing: off 423 | template-curly-spacing: off 424 | template-tag-spacing: off 425 | unicode-bom: off 426 | wrap-iife: off 427 | wrap-regex: off 428 | yield-star-spacing: off 429 | 430 | overrides: 431 | - files: '**/*.ts' 432 | parser: '@typescript-eslint/parser' 433 | parserOptions: 434 | sourceType: module 435 | project: ['tsconfig.json'] 436 | plugins: 437 | - '@typescript-eslint' 438 | extends: 439 | - plugin:import/typescript 440 | rules: 441 | ########################################################################## 442 | # `@typescript-eslint/eslint-plugin` rule list based on `v4.28.x` 443 | ########################################################################## 444 | 445 | # Supported Rules 446 | # https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules 447 | '@typescript-eslint/adjacent-overload-signatures': error 448 | '@typescript-eslint/array-type': [error, { default: generic }] 449 | '@typescript-eslint/await-thenable': error 450 | '@typescript-eslint/ban-ts-comment': [error, { 'ts-expect-error': false }] 451 | '@typescript-eslint/ban-tslint-comment': error 452 | '@typescript-eslint/ban-types': error 453 | '@typescript-eslint/class-literal-property-style': error 454 | '@typescript-eslint/consistent-indexed-object-style': off # TODO enable after TS conversion 455 | '@typescript-eslint/consistent-type-assertions': 456 | [error, { assertionStyle: as, objectLiteralTypeAssertions: never }] 457 | '@typescript-eslint/consistent-type-definitions': error 458 | '@typescript-eslint/consistent-type-imports': off # TODO enable after TS conversion 459 | '@typescript-eslint/explicit-function-return-type': off # TODO consider 460 | '@typescript-eslint/explicit-member-accessibility': off # TODO consider 461 | '@typescript-eslint/explicit-module-boundary-types': off # TODO consider 462 | '@typescript-eslint/member-ordering': off # TODO consider 463 | '@typescript-eslint/method-signature-style': error 464 | '@typescript-eslint/naming-convention': off # TODO consider 465 | '@typescript-eslint/no-base-to-string': error 466 | '@typescript-eslint/no-confusing-non-null-assertion': error 467 | '@typescript-eslint/no-confusing-void-expression': error 468 | '@typescript-eslint/no-dynamic-delete': off 469 | '@typescript-eslint/no-empty-interface': error 470 | '@typescript-eslint/no-explicit-any': off # TODO error 471 | '@typescript-eslint/no-extra-non-null-assertion': error 472 | '@typescript-eslint/no-extraneous-class': off # TODO consider 473 | '@typescript-eslint/no-floating-promises': error 474 | '@typescript-eslint/no-for-in-array': error 475 | '@typescript-eslint/no-implicit-any-catch': error 476 | '@typescript-eslint/no-implied-eval': error 477 | '@typescript-eslint/no-inferrable-types': 478 | [error, { ignoreParameters: true, ignoreProperties: true }] 479 | '@typescript-eslint/no-misused-new': error 480 | '@typescript-eslint/no-misused-promises': error 481 | '@typescript-eslint/no-namespace': error 482 | '@typescript-eslint/no-non-null-asserted-optional-chain': error 483 | '@typescript-eslint/no-non-null-assertion': error 484 | '@typescript-eslint/no-parameter-properties': error 485 | '@typescript-eslint/no-invalid-void-type': error 486 | '@typescript-eslint/no-require-imports': error 487 | '@typescript-eslint/no-this-alias': error 488 | '@typescript-eslint/no-type-alias': off # TODO consider 489 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': error 490 | '@typescript-eslint/no-unnecessary-condition': off # TODO temporarily disabled 491 | '@typescript-eslint/no-unnecessary-qualifier': error 492 | '@typescript-eslint/no-unnecessary-type-arguments': error 493 | '@typescript-eslint/no-unnecessary-type-assertion': error 494 | '@typescript-eslint/no-unnecessary-type-constraint': off # TODO consider 495 | '@typescript-eslint/no-unsafe-assignment': off # TODO consider 496 | '@typescript-eslint/no-unsafe-call': off # TODO consider 497 | '@typescript-eslint/no-unsafe-member-access': off # TODO consider 498 | '@typescript-eslint/no-unsafe-return': off # TODO consider 499 | '@typescript-eslint/no-var-requires': error 500 | '@typescript-eslint/non-nullable-type-assertion-style': error 501 | '@typescript-eslint/prefer-as-const': off # TODO consider 502 | '@typescript-eslint/prefer-enum-initializers': off # TODO consider 503 | '@typescript-eslint/prefer-for-of': error 504 | '@typescript-eslint/prefer-function-type': error 505 | '@typescript-eslint/prefer-includes': off # TODO switch to error after IE11 drop 506 | '@typescript-eslint/prefer-literal-enum-member': error 507 | '@typescript-eslint/prefer-namespace-keyword': error 508 | '@typescript-eslint/prefer-nullish-coalescing': error 509 | '@typescript-eslint/prefer-optional-chain': error 510 | '@typescript-eslint/prefer-readonly': error 511 | '@typescript-eslint/prefer-readonly-parameter-types': off # TODO consider 512 | '@typescript-eslint/prefer-reduce-type-parameter': error 513 | '@typescript-eslint/prefer-regexp-exec': error 514 | '@typescript-eslint/prefer-ts-expect-error': error 515 | '@typescript-eslint/prefer-string-starts-ends-with': off # TODO switch to error after IE11 drop 516 | '@typescript-eslint/promise-function-async': off 517 | '@typescript-eslint/require-array-sort-compare': error 518 | '@typescript-eslint/restrict-plus-operands': 519 | [error, { checkCompoundAssignments: true }] 520 | '@typescript-eslint/restrict-template-expressions': error 521 | '@typescript-eslint/sort-type-union-intersection-members': off # TODO consider 522 | '@typescript-eslint/strict-boolean-expressions': off # TODO consider 523 | '@typescript-eslint/switch-exhaustiveness-check': error 524 | '@typescript-eslint/triple-slash-reference': error 525 | '@typescript-eslint/typedef': off 526 | '@typescript-eslint/unbound-method': off # TODO consider 527 | '@typescript-eslint/unified-signatures': error 528 | 529 | # Extension Rules 530 | # https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#extension-rules 531 | 532 | # Disable conflicting ESLint rules and enable TS-compatible ones 533 | default-param-last: off 534 | dot-notation: off 535 | lines-between-class-members: off 536 | no-array-constructor: off 537 | no-dupe-class-members: off 538 | no-empty-function: off 539 | no-invalid-this: off 540 | no-loop-func: off 541 | no-loss-of-precision: off 542 | no-redeclare: off 543 | no-throw-literal: off 544 | no-shadow: off 545 | no-unused-expressions: off 546 | no-unused-vars: off 547 | no-useless-constructor: off 548 | require-await: off 549 | no-return-await: off 550 | '@typescript-eslint/default-param-last': error 551 | '@typescript-eslint/dot-notation': error 552 | '@typescript-eslint/lines-between-class-members': 553 | [error, always, { exceptAfterSingleLine: true }] 554 | '@typescript-eslint/no-array-constructor': error 555 | '@typescript-eslint/no-dupe-class-members': error 556 | '@typescript-eslint/no-empty-function': error 557 | '@typescript-eslint/no-invalid-this': error 558 | '@typescript-eslint/no-loop-func': error 559 | '@typescript-eslint/no-loss-of-precision': error 560 | '@typescript-eslint/no-redeclare': error 561 | '@typescript-eslint/no-throw-literal': error 562 | '@typescript-eslint/no-shadow': error 563 | '@typescript-eslint/no-unused-expressions': error 564 | '@typescript-eslint/no-unused-vars': 565 | [ 566 | error, 567 | { 568 | vars: all, 569 | args: all, 570 | argsIgnorePattern: '^_', 571 | varsIgnorePattern: '^_T', 572 | }, 573 | ] 574 | '@typescript-eslint/no-useless-constructor': error 575 | '@typescript-eslint/require-await': error 576 | '@typescript-eslint/return-await': error 577 | 578 | # Disable for JS and TS 579 | '@typescript-eslint/init-declarations': off 580 | '@typescript-eslint/no-magic-numbers': off 581 | '@typescript-eslint/no-use-before-define': off 582 | '@typescript-eslint/no-duplicate-imports': off # Superseded by `import/no-duplicates` 583 | 584 | # Bellow rules are disabled because coflicts with Prettier, see: 585 | # https://github.com/prettier/eslint-config-prettier/blob/master/%40typescript-eslint.js 586 | '@typescript-eslint/object-curly-spacing': off 587 | '@typescript-eslint/quotes': off 588 | '@typescript-eslint/brace-style': off 589 | '@typescript-eslint/comma-dangle': off 590 | '@typescript-eslint/comma-spacing': off 591 | '@typescript-eslint/func-call-spacing': off 592 | '@typescript-eslint/indent': off 593 | '@typescript-eslint/keyword-spacing': off 594 | '@typescript-eslint/member-delimiter-style': off 595 | '@typescript-eslint/no-extra-parens': off 596 | '@typescript-eslint/no-extra-semi': off 597 | '@typescript-eslint/semi': off 598 | '@typescript-eslint/space-before-function-paren': off 599 | '@typescript-eslint/space-infix-ops': off 600 | '@typescript-eslint/type-annotation-spacing': off 601 | - files: 'src/**/__*__/**' 602 | rules: 603 | node/no-unpublished-import: off 604 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 605 | - files: 'resources/**' 606 | env: 607 | node: true 608 | rules: 609 | node/no-unpublished-require: off 610 | node/no-sync: off 611 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 612 | import/no-nodejs-modules: off 613 | import/no-commonjs: off 614 | no-console: off 615 | --------------------------------------------------------------------------------