├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc ├── MIT-LICENSE.txt ├── README.md ├── babel.config.js ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── api-reference │ │ ├── _category_.json │ │ ├── createCacheUpdaterLink.md │ │ ├── usePagination.md │ │ └── withCacheUpdater.md │ ├── directives │ │ ├── _category_.json │ │ ├── appendNode-prependNode.md │ │ ├── argumentDefinitions.md │ │ ├── arguments.md │ │ ├── deleteRecord.md │ │ ├── pagination.md │ │ └── refetchable.md │ ├── getting-started │ │ ├── _category_.json │ │ ├── basic-usage │ │ │ ├── _category_.json │ │ │ ├── pagination.md │ │ │ └── updating-data.md │ │ ├── configuration.md │ │ └── installation.md │ ├── graphql-code-generator │ │ ├── _category_.json │ │ └── graphql-codegen-preset.md │ ├── guides │ │ ├── _category_.json │ │ ├── ide-settings.md │ │ ├── local-variables.md │ │ ├── pagination.md │ │ ├── relay-spec.md │ │ └── updating-data.md │ └── introduction.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ └── index.tsx ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ └── logo.svg ├── tsconfig.json └── yarn.lock ├── examples └── app │ ├── .env.development │ ├── .prettierignore │ ├── README.md │ ├── apollo.config.js │ ├── codegen.yml │ ├── graphql.config.js │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── schema.graphql │ ├── server-tsconfig.json │ ├── src │ ├── App.css │ ├── App.tsx │ ├── List.tsx │ ├── ListItem.tsx │ ├── generated │ │ ├── graphql.ts │ │ └── introspection-result.json │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ └── server │ │ └── index.ts │ └── tsconfig.json ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── config │ ├── README.md │ ├── package.json │ ├── scripts │ │ └── generateClientSchema.js │ ├── src │ │ ├── __tests__ │ │ │ └── clientSchema.test.ts │ │ ├── apolloConfig.ts │ │ ├── clientDirective.ts │ │ ├── clientSchema.ts │ │ ├── dependencies │ │ │ ├── apollo.ts │ │ │ └── graphql.ts │ │ ├── index.ts │ │ └── validationRules │ │ │ ├── __tests__ │ │ │ └── paginationDirective.test.ts │ │ │ ├── index.ts │ │ │ ├── paginationDirective.ts │ │ │ └── validationRule.ts │ └── tsconfig.build.json ├── core │ ├── README.md │ ├── package.json │ ├── src │ │ ├── hooks │ │ │ ├── __tests__ │ │ │ │ ├── usePagination.mock.ts │ │ │ │ └── usePagination.test.ts │ │ │ ├── index.ts │ │ │ └── usePagination.ts │ │ ├── index.ts │ │ ├── links │ │ │ ├── __tests__ │ │ │ │ └── cacheUpdaterLink.test.ts │ │ │ ├── cacheUpdaterLink.ts │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── policies │ │ │ ├── cacheUpdater │ │ │ │ ├── __tests__ │ │ │ │ │ ├── deleteRecord.mock.ts │ │ │ │ │ ├── deleteRecord.test.ts │ │ │ │ │ └── pagination │ │ │ │ │ │ ├── appendNode.mock.ts │ │ │ │ │ │ ├── appendNode.test.ts │ │ │ │ │ │ ├── connectionId.mock.ts │ │ │ │ │ │ ├── connectionId.test.ts │ │ │ │ │ │ ├── mock.ts │ │ │ │ │ │ ├── prependNode.mock.ts │ │ │ │ │ │ └── prependNode.test.ts │ │ │ │ ├── deleteRecord.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pagination.ts │ │ │ │ ├── util.ts │ │ │ │ └── withCacheUpdater.ts │ │ │ └── index.ts │ │ └── utils │ │ │ ├── directiveName.ts │ │ │ ├── getNodes.ts │ │ │ ├── graphqlAST.ts │ │ │ ├── index.ts │ │ │ └── testing │ │ │ ├── backendNodeIdGenerator.ts │ │ │ └── mockedWrapperComponent.tsx │ └── tsconfig.build.json └── graphql-codegen-preset │ ├── README.md │ ├── package.json │ ├── src │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── preset.test.ts.snap │ │ ├── fixtures │ │ │ └── exampleFile.ts │ │ └── preset.test.ts │ ├── index.ts │ ├── plugins │ │ └── cache-updater-support │ │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ └── visitor.ts │ ├── preset.ts │ ├── presetConfig.ts │ ├── schemaTransforms │ │ ├── __tests__ │ │ │ └── addConnectionId.test.ts │ │ ├── addClientDirective.ts │ │ └── addConnectionId.ts │ ├── transforms │ │ ├── __tests__ │ │ │ ├── addFieldsForAddingNode.test.ts │ │ │ ├── addPaginationFields.test.ts │ │ │ ├── fixVariableNotDefinedInRoot.test.ts │ │ │ ├── generateRefetchQuery.test.ts │ │ │ ├── passArgumentValueToFragment.test.ts │ │ │ └── removeCustomDirective.test.ts │ │ ├── addFieldsForAddingNode.ts │ │ ├── addPaginationFields.ts │ │ ├── fixVariableNotDefinedInRoot.ts │ │ ├── generateRefetchQuery.ts │ │ ├── passArgumentValueToFragment.ts │ │ ├── removeCustomDirective.ts │ │ └── util.ts │ └── utils │ │ ├── graphqlAST.ts │ │ ├── graphqlSchema.ts │ │ ├── nonNullable.ts │ │ └── testing │ │ ├── example.graphql │ │ └── utils.ts │ └── tsconfig.build.json ├── setupJest.js ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | */backend/* 2 | **/*.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:@typescript-eslint/eslint-recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 6 | 'plugin:prettier/recommended', 7 | 'prettier', 8 | ], 9 | env: { 10 | es6: true, 11 | node: true, 12 | }, 13 | parser: '@typescript-eslint/parser', 14 | plugins: ['@typescript-eslint/eslint-plugin', 'prettier'], 15 | parserOptions: { 16 | project: './tsconfig.json', 17 | tsconfigRootDir: __dirname, 18 | }, 19 | rules: { 20 | '@typescript-eslint/no-unused-vars': 'error', 21 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md*' 9 | pull_request: 10 | paths-ignore: 11 | - '**.md*' 12 | schedule: 13 | # Check peerDependency packages changes do not break this library 14 | - cron: '0 0 * * 1' 15 | 16 | jobs: 17 | ci: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | node-version: 22 | - 14.x 23 | - 16.x 24 | command: 25 | - 'lint' 26 | - 'test' 27 | steps: 28 | - name: Checkout repo 29 | uses: actions/checkout@v2 30 | 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | 36 | - name: Install dependencies 37 | run: yarn && yarn run lerna bootstrap 38 | 39 | - name: Build packages 40 | run: yarn build 41 | 42 | - name: Run ${{ matrix.command }} 43 | run: yarn ${{ matrix.command }} 44 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '44 2 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | */node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # TernJS port file 81 | .tern-port 82 | 83 | # Stores VSCode versions used for testing VSCode extensions 84 | .vscode-test 85 | 86 | # yarn v2 87 | .yarn/cache 88 | .yarn/unplugged 89 | .yarn/build-state.yml 90 | .yarn/install-state.gz 91 | .pnp.* 92 | 93 | # build output 94 | dist/ 95 | 96 | # Others 97 | .DS_Store 98 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2021 Kyohei Nakamori 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Nau 4 |

5 | 6 | Nau is a tool that makes [Apollo Client](https://github.com/apollographql/apollo-client) more productive for users using [Relay GraphQL Server Specification](https://relay.dev/docs/guides/graphql-server-specification) compliant backends. 7 | 8 | - Make cache operations very easy 9 | - Provide custom directives to write declaratively and improve productivity 10 | - Support co-location of components and fragments by allowing a query to split into the fragments 11 | - Support subscriptions 12 | 13 | The tool aims to help frontend developers build frontend applications more quickly, with fewer bugs, and more efficiently. 14 | 15 | Nau is a perfect fit with React, Apollo Client, GraphQL Code Generator, and TypeScript. 16 | 17 | 18 | ## Installation 19 | ``` 20 | yarn add @kazekyo/nau 21 | yarn add --dev @kazekyo/nau-graphql-codegen-preset 22 | ``` 23 | 24 | > ⚠️ The packages are currently under development. All version updates may have breaking changes. 25 | 26 | ## Documentation 27 | https://www.naugraphql.com 28 | 29 | 30 | ## Example 31 | ![nau-demo](https://user-images.githubusercontent.com/456381/168417859-df6222b4-ae80-4dd1-a4ef-9117536bef14.gif) 32 | 33 | See the [example code](https://github.com/kazekyo/nau/tree/main/examples/app). 34 | 35 | 36 | You can write pagination and cache updates more easily using some GraphQL directives. :rocket: 37 | ```src/List.tsx 38 | import { gql, useMutation, useSubscription } from '@apollo/client'; 39 | import { usePagination } from '@kazekyo/nau'; 40 | import * as React from 'react'; 41 | import { 42 | AddItemMutationDocument, 43 | ItemAddedSubscriptionDocument, 44 | ItemRemovedSubscriptionDocument, 45 | List_PaginationQueryDocument, 46 | List_UserFragment 47 | } from './generated/graphql'; 48 | import ListItem from './ListItem'; 49 | 50 | gql` 51 | fragment List_user on User 52 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 53 | @refetchable(queryName: "List_PaginationQuery") { 54 | items(first: $count, after: $cursor) @pagination { 55 | edges { 56 | node { 57 | ...ListItem_item 58 | } 59 | } 60 | } 61 | ...ListItem_user 62 | } 63 | 64 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 65 | addItem(input: $input) { 66 | item @prependNode(connections: $connections) { 67 | ...ListItem_item 68 | } 69 | } 70 | } 71 | 72 | subscription ItemAddedSubscription($connections: [String!]!) { 73 | itemAdded { 74 | item @prependNode(connections: $connections) { 75 | ...ListItem_item 76 | } 77 | } 78 | } 79 | 80 | subscription ItemRemovedSubscription { 81 | itemRemoved { 82 | id @deleteRecord(typename: "Item") 83 | } 84 | } 85 | `; 86 | 87 | const List: React.FC<{ user: List_UserFragment }> = ({ user }) => { 88 | useSubscription(ItemAddedSubscriptionDocument, { 89 | variables: { 90 | connections: [user.items._connectionId], 91 | }, 92 | }); 93 | useSubscription(ItemRemovedSubscriptionDocument); 94 | const [addItem] = useMutation(AddItemMutationDocument); 95 | const { nodes, hasNext, loadNext, isLoading } = usePagination(List_PaginationQueryDocument, { 96 | id: user.id, 97 | connection: user.items, 98 | }); 99 | 100 | return ( 101 | <> 102 |
103 | 115 |
116 |
117 | {nodes.map((node) => { 118 | return ( 119 |
120 | 121 |
122 | ); 123 | })} 124 |
125 | {hasNext && ( 126 | 129 | )} 130 | 131 | ); 132 | }; 133 | 134 | export default List; 135 | 136 | ``` 137 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['@babel/env', '@babel/typescript'], 5 | plugins: ['@babel/proposal-class-properties', '@babel/proposal-object-rest-spread'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/api-reference/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "API Reference", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/api-reference/createCacheUpdaterLink.md: -------------------------------------------------------------------------------- 1 | # createCacheUpdaterLink 2 | 3 | `createCacheUpdaterLink` creates an Apollo Link for Nau to manipulate the cache. 4 | 5 | ```tsx 6 | const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); 7 | // highlight-next-line 8 | const cacheUpdaterLink = createCacheUpdaterLink(); 9 | 10 | const client = new ApolloClient({ 11 | cache: new InMemoryCache({ 12 | addTypename: true, 13 | possibleTypes: introspectionResult.possibleTypes, 14 | typePolicies: withCacheUpdater({}), 15 | }), 16 | // highlight-next-line 17 | link: from([cacheUpdaterLink, httpLink]), 18 | }); 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/api-reference/usePagination.md: -------------------------------------------------------------------------------- 1 | # usePagination 2 | 3 | The `usePagination` is Hook for easy handling of pagination. 4 | 5 | ```tsx 6 | const { nodes, hasNext, loadNext, isLoading } = usePagination(List_PaginationQueryDocument, { 7 | id: user.id, 8 | connection: user.items, 9 | }); 10 | ``` 11 | 12 | ## Arguments 13 | - `document`: A GraphQL query document. 14 | - `options`: 15 | - `id`: A id of an object having Connection. 16 | - `connection`: A connection field. 17 | - `variables`: Variables required for query. 18 | 19 | 20 | ## Result 21 | - `nodes`: Array of nodes retrieved from a connection. 22 | - `loadNext`: A function used to fetch items on the next page in a connection. 23 | - `loadPrevious`: A function used to fetch items on the previous page in a connection. 24 | - `hasNext`: A value indicating whether item exists on the next page. 25 | - `hasPrevious`: A value indicating whether item exists on the previous page. 26 | - `isLoading`: value indicating whether the next/previous page is currently loading. 27 | -------------------------------------------------------------------------------- /docs/docs/api-reference/withCacheUpdater.md: -------------------------------------------------------------------------------- 1 | # withCacheUpdater 2 | 3 | The `withCacheUpdater` allows you to use some tools for updating the cache, such as `@appendNode`. 4 | 5 | ```tsx 6 | new InMemoryCache({ 7 | addTypename: true, 8 | possibleTypes: introspectionResult.possibleTypes, 9 | // highlight-start 10 | typePolicies: withCacheUpdater({ 11 | User: { 12 | fields: { 13 | items: relayStylePagination(), 14 | }, 15 | }, 16 | }), 17 | // highlight-end 18 | }), 19 | ``` 20 | 21 | 22 | ## Arguments 23 | - `typePolicies`: A `TypePolicy` object. Set `relayStylePagination()` for fields to which `@pagination` is attached. 24 | -------------------------------------------------------------------------------- /docs/docs/directives/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Directives", 3 | "position": 5 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/directives/appendNode-prependNode.md: -------------------------------------------------------------------------------- 1 | # @appendNode / @prependNode 2 | 3 | `@appendNode` and `@prependNode` will automatically add data from a mutation result to a connection on the cache. 4 | 5 | The `@appendNode` will add an element to the bottom of a list, and the `@prependNode` will add it to the top. 6 | ### `@appendNode` 7 | ```graphql 8 | mutation AddBar($input: AddBarInput!, $connections: [String!]!) { 9 | addBar(input: $input) { 10 | bar @appendNode(connections: $connections) { 11 | name 12 | } 13 | } 14 | } 15 | ``` 16 | 17 | ### `@prependNode` 18 | ```graphql 19 | mutation AddBar($input: AddBarInput!, $connections: [String!]!) { 20 | addBar(input: $input) { 21 | bar @appendNode(connections: $connections) { 22 | name 23 | } 24 | } 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/docs/directives/argumentDefinitions.md: -------------------------------------------------------------------------------- 1 | # @argumentDefinitions 2 | 3 | The `@argumentDefinitions` allows you to define fragment-specific variables. You can also set default values for the variables. 4 | 5 | ```graphql 6 | fragment foo on Foo @argumentDefinitions(arg1: { type: "String!" }, arg2: { type: "Int!", defaultValue: 0 }) { 7 | foo(a: $arg1, b: $arg2) { 8 | id 9 | name 10 | } 11 | } 12 | `; 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/docs/directives/arguments.md: -------------------------------------------------------------------------------- 1 | # @arguments 2 | 3 | Use the `@arguments` directive to pass variables to fragments. 4 | 5 | ```graphql 6 | query MyQuery($myName: String!) { 7 | foo { 8 | ...bar1 @arguments(name: $myName) 9 | ...bar2 @arguments(name: "your name") 10 | } 11 | } 12 | 13 | fragment bar1 on Bar @argumentDefinitions(name: { type: "String!" }) { 14 | field1(name: $name) { 15 | id 16 | } 17 | } 18 | 19 | fragment bar2 on Bar @argumentDefinitions(name: { type: "String!" }) { 20 | field2(name: $name) { 21 | id 22 | } 23 | } 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/docs/directives/deleteRecord.md: -------------------------------------------------------------------------------- 1 | # @deleteRecord 2 | 3 | Use the `@deleteRecord` directive to delete a item in the cache based on a mutation result. 4 | 5 | You can put this directive on only a `id` field. The `typename` argument is the type of data to be deleted. 6 | 7 | ```graphql 8 | mutation RemoveItemMutation($input: RemoveItemInput!) { 9 | removeItem(input: $input) { 10 | removedItem { 11 | id @deleteRecord(typename: "Item") 12 | } 13 | } 14 | } 15 | ``` 16 | 17 | Using this directive will delete the data from the cache. So if you have the item in connections A and B, `@deleteRecord` will remove the data from both connections. 18 | -------------------------------------------------------------------------------- /docs/docs/directives/pagination.md: -------------------------------------------------------------------------------- 1 | # @pagination 2 | 3 | By attaching the `pagination` directive, you indicate to Nau that this is a connection for pagination. 4 | 5 | Nau will determine fields you have not written in your query (e.g. the `pageInfo` field) and add them if necessary. It also generates some TypeScript code for caching. 6 | 7 | For example, let's look at a following query: 8 | ```graphql 9 | query TestQuery($cursor: String) { 10 | viewer { 11 | // highlight-next-line 12 | items(first: 1, after: $cursor) @pagination { 13 | edges { 14 | node { 15 | name 16 | } 17 | } 18 | } 19 | } 20 | } 21 | ``` 22 | Nau will rewrite the query as follows: 23 | ```graphql 24 | query TestQuery($cursor: String) { 25 | viewer { 26 | items(first: 1, after: $cursor) @pagination { 27 | edges { 28 | node { 29 | name 30 | // highlight-start 31 | id 32 | __typename 33 | // highlight-end 34 | } 35 | // highlight-next-line 36 | cursor 37 | } 38 | // highlight-start 39 | pageInfo { 40 | hasNextPage 41 | endCursor 42 | hasPreviousPage 43 | startCursor 44 | } 45 | _connectionId @client 46 | // highlight-end 47 | } 48 | // highlight-start 49 | id 50 | __typename 51 | // highlight-end 52 | } 53 | } 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/docs/directives/refetchable.md: -------------------------------------------------------------------------------- 1 | # @refetchable 2 | 3 | Using the `@refetchable` directive, Nau can automatically create a query containing only a fragment. You need to set `queryName` to a name of the query to refetch. 4 | 5 | For example, let's look at a following fragment: 6 | ```graphql 7 | fragment List_user on User 8 | @refetchable(queryName: "List_PaginationQuery") { 9 | id 10 | name 11 | } 12 | ``` 13 | 14 | Nau will generate a query as follows: 15 | ```graphql 16 | query List_PaginationQuery($id: ID!) { 17 | node(id: $id) { 18 | id 19 | __typename 20 | ...List_user 21 | } 22 | } 23 | 24 | fragment List_user on User 25 | @refetchable(queryName: "List_PaginationQuery") { 26 | id 27 | name 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/docs/getting-started/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Getting Started", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/getting-started/basic-usage/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Basic Usage", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/getting-started/basic-usage/pagination.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Pagination 6 | 7 | By using some directives and hooks, you can paginate with less code while making efficient API requests. 8 | 9 | Here is an example component. Let's implement it using this component. 10 | 11 | ```tsx title="src/List.tsx" 12 | import { List_PaginationQueryDocument, List_UserFragment } from './generated/graphql'; 13 | 14 | gql` /* GraphQL */ 15 | fragment List_user on User { 16 | id 17 | items(first: $count, after: $cursor) { 18 | edges { 19 | node { 20 | id 21 | name 22 | } 23 | cursor 24 | } 25 | pageInfo { 26 | hasNextPage 27 | endCursor 28 | } 29 | } 30 | } 31 | `; 32 | 33 | const List: React.FC<{ user: List_UserFragment; }> = ({ user }) => { 34 | // Retrieve nodes from user 35 | // ... 36 | return ( 37 | <> 38 |
39 | {nodes.map((node, i) => { 40 | return ( 41 |
{node.name}
42 | ); 43 | })} 44 |
45 | {/* ... */} 46 | 47 | ); 48 | }; 49 | ``` 50 | 51 | First, add the following directives: `@argumentDefinitions`, `@refetchable`, and `@pagination`. You may also remove some fields only for pagination, such as `pageInfo`, from the fragment, as Nau will automatically complete them for you. 52 | 53 | ```graphql 54 | fragment List_user on User 55 | // highlight-start 56 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 57 | @refetchable(queryName: "List_PaginationQuery") { 58 | // highlight-end 59 | id 60 | // highlight-next-line 61 | items(first: $count, after: $cursor) @pagination { 62 | edges { 63 | node { 64 | name 65 | } 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | Run `graphql-codegen`. 72 | ```bash 73 | yarn graphql-codegen 74 | ``` 75 | 76 | You can easily implement pagination using the generated `List_PaginationQueryDocument` with `usePagination`. 77 | 78 | ```tsx title="src/List.tsx" 79 | // highlight-start 80 | import { usePagination } from '@kazekyo/nau'; 81 | import { List_PaginationQueryDocument, List_UserFragment } from './generated/graphql'; 82 | // highlight-end 83 | 84 | gql` /* GraphQL */ 85 | fragment List_user on User 86 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 87 | @refetchable(queryName: "List_PaginationQuery") { 88 | id 89 | items(first: $count, after: $cursor) @pagination { 90 | edges { 91 | node { 92 | name 93 | } 94 | } 95 | } 96 | } 97 | `; 98 | 99 | const List: React.FC<{ user: List_UserFragment; }> = ({ user }) => { 100 | // highlight-start 101 | const { nodes, hasNext, loadNext, isLoading } = usePagination(List_PaginationQueryDocument, { 102 | id: user.id, 103 | connection: user.items, 104 | }); 105 | // highlight-end 106 | 107 | return ( 108 | <> 109 |
110 | {nodes.map((node, i) => { 111 | return ( 112 |
{node.name}
113 | ); 114 | })} 115 |
116 | // highlight-start 117 | {hasNext && ( 118 | 125 | )} 126 | // highlight-end 127 | 128 | ); 129 | }; 130 | ``` 131 | 132 | Note that whether you are using Nau or not, remember that you must have pagination configured in your cache. 133 | 134 | ```tsx title="src/index.tsx" 135 | // highlight-next-line 136 | import { relayStylePagination } from '@apollo/client/utilities'; 137 | 138 | const client = new ApolloClient({ 139 | cache: new InMemoryCache({ 140 | addTypename: true, 141 | possibleTypes: introspectionResult.possibleTypes, 142 | typePolicies: withCacheUpdater({ 143 | // highlight-start 144 | User: { 145 | fields: { 146 | items: relayStylePagination(), 147 | }, 148 | }, 149 | // highlight-end 150 | }), 151 | }), 152 | link: splitLink, 153 | }); 154 | ``` 155 | -------------------------------------------------------------------------------- /docs/docs/getting-started/basic-usage/updating-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Updating Data 6 | 7 | Nau can automatically add objects to a list (connection) on cache based on mutation results. Using directives such as @appendNode will automatically add a element to a connection on the cache based on mutation results. 8 | 9 | Let's look at an example mutation. 10 | ```graphql 11 | mutation AddItemMutation($input: AddItemInput!) { 12 | addItem(input: $input) { 13 | item { 14 | name 15 | } 16 | } 17 | } 18 | ``` 19 | 20 | Attach @prependNode to the `item` field. Using `@prependNode` you can add the element to the top of the list. 21 | 22 | ```graphql 23 | // highlight-next-line 24 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 25 | // highlight-next-line 26 | addItem(input: $input) @prependNode(connections: $connections) { 27 | item { 28 | name 29 | } 30 | } 31 | } 32 | ``` 33 | 34 | Run `graphql-codegen`. 35 | ```bash 36 | yarn graphql-codegen 37 | ``` 38 | 39 | Let's try to use this mutation. 40 | 41 | ```tsx title="src/List.tsx" 42 | import { 43 | // highlight-next-line 44 | AddItemMutationDocument, 45 | List_PaginationQueryDocument, 46 | List_UserFragment, 47 | } from './generated/graphql'; 48 | 49 | gql` /* GraphQL */ 50 | fragment List_user on User 51 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 52 | @refetchable(queryName: "List_PaginationQuery") { 53 | id 54 | items(first: $count, after: $cursor) @pagination { 55 | edges { 56 | node { 57 | name 58 | } 59 | } 60 | } 61 | } 62 | 63 | // highlight-start 64 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 65 | addItem(input: $input) { 66 | item @prependNode(connections: $connections) { 67 | name 68 | } 69 | } 70 | } 71 | // highlight-end 72 | `; 73 | 74 | const List: React.FC<{ user: List_UserFragment }> = ({ user }) => { 75 | // ... 76 | 77 | // highlight-next-line 78 | const [addItem] = useMutation(AddItemMutationDocument); 79 | 80 | return ( 81 | <> 82 | {/* ... */} 83 | 97 | {/* ... */} 98 | 99 | ); 100 | }; 101 | ``` 102 | 103 | Now you can add the element to the top of the list based on the mutation result. 104 | 105 | Using [`@appendNode`](/docs/directives/appendNode-prependNode), you can do the opposite, adding the element to the bottom of the list. Other directives such as [`@deleteRecord`](/docs/directives/deleteRecord) delete data based on mutation results. 106 | -------------------------------------------------------------------------------- /docs/docs/getting-started/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Configuration 6 | 7 | You should be familiar with [React](https://reactjs.org/), [Apollo Client](https://www.apollographql.com/docs/react/), and [GraphQL Code Generator](https://www.graphql-code-generator.com/) as a prerequisite. If you have never used them, you will need to read their documentation first. 8 | 9 | # Configration for Nau 10 | First, add `@kazekyo/nau-graphql-codegen-preset` to your `codegen.yml`. 11 | 12 | ```yaml title="./codegen.yml" 13 | schema: http://localhost:4000/graphql 14 | # Do not use the root document. For example, do not write `documents: src/**/*.graphql` this line. 15 | generates: 16 | src/generated/graphql.ts: 17 | // highlight-start 18 | preset: '@kazekyo/nau-graphql-codegen-preset' 19 | presetConfig: 20 | generateTypeScriptCode: true 21 | documents: 22 | - src/**/*.tsx 23 | - src/**/*.graphql 24 | // highlight-end 25 | plugins: 26 | - typescript 27 | - typescript-operations 28 | - typed-document-node 29 | src/generated/introspection-result.json: 30 | plugins: 31 | - fragment-matcher 32 | ``` 33 | 34 | As above, you will need to add this preset to processes that read `documents` and use it. In most cases, that means you add the preset to processes of generating files that output TypeScript code. 35 | 36 | :::info 37 | We recommend not using root `documents`. It means that you should **not** write the following: 38 | 39 | ```yaml title="./codegen.yml" 40 | schema: http://localhost:4000/graphql 41 | // highlight-start 42 | documents: 43 | - src/**/*.tsx 44 | - src/**/*.graphql 45 | // highlight-end 46 | generates: 47 | # ... 48 | ``` 49 | 50 | Using root `documents` requires using the preset in all file generation process to parse directives provided by Nau. 51 | 52 | ::: 53 | 54 | 55 | Run GraphQL Code Generator. 56 | ```bash 57 | yarn graphql-codegen 58 | ``` 59 | 60 | And Configure your cache settings for Apollo Client. Import and use `withCacheUpdater` from the generated file, and also use some functions of `@kazekyo/nau`. 61 | ```tsx title="src/index.tsx" 62 | import { ApolloClient, ApolloProvider, from, HttpLink, InMemoryCache, split } from '@apollo/client'; 63 | import { WebSocketLink } from '@apollo/client/link/ws'; 64 | // highlight-next-line 65 | import { createCacheUpdaterLink, isSubscriptionOperation } from '@kazekyo/nau'; 66 | // highlight-next-line 67 | import { withCacheUpdater } from './generated/graphql'; 68 | // highlight-next-line 69 | import introspectionResult from './generated/introspection-result.json'; 70 | 71 | const wsLink = new WebSocketLink({ 72 | uri: 'ws://localhost:4000/subscriptions', 73 | options: { 74 | reconnect: true, 75 | }, 76 | }); 77 | const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); 78 | // highlight-next-line 79 | const cacheUpdaterLink = createCacheUpdaterLink(); 80 | 81 | const splitLink = split( 82 | ({ query }) => isSubscriptionOperation(query), 83 | // highlight-start 84 | from([cacheUpdaterLink, wsLink]), 85 | from([cacheUpdaterLink, httpLink]), 86 | // highlight-end 87 | ); 88 | 89 | const client = new ApolloClient({ 90 | cache: new InMemoryCache({ 91 | // highlight-start 92 | addTypename: true, // Do not set false 93 | possibleTypes: introspectionResult.possibleTypes, 94 | typePolicies: withCacheUpdater({}), 95 | // highlight-end 96 | }), 97 | link: splitLink, 98 | }); 99 | ``` 100 | 101 | That's it for the configuration! If you want to set up your IDE, see also [here](/docs/guides/ide-settings). 102 | -------------------------------------------------------------------------------- /docs/docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Installation 4 | --- 5 | 6 | ```mdx-code-block 7 | import Tabs from '@theme/Tabs'; 8 | import TabItem from '@theme/TabItem'; 9 | ``` 10 | 11 | You can install packages. 12 | 13 | ````mdx-code-block 14 | 15 | 16 | 17 | ```bash 18 | npm install @kazekyo/nau 19 | npm install --save-dev @kazekyo/nau-graphql-codegen-preset 20 | ``` 21 | 22 | 23 | 24 | 25 | ```bash 26 | yarn add @kazekyo/nau 27 | yarn add --dev @kazekyo/nau-graphql-codegen-preset 28 | ``` 29 | 30 | 31 | 32 | ```` 33 | 34 | 35 | You need **Apollo Client** and **GraphQL Code Generator** to use Nau. And GraphQL Code Generator plugins such as `typescript`, `typescript-operations`, `typed-document-node`, `fragment-matcher`, and `schema-ast` are strongly recommended. 36 | 37 | If you have not installed them, do so. 38 | 39 | ````mdx-code-block 40 | 41 | 42 | 43 | ```bash 44 | npm install @apollo/client graphql 45 | npm install --save-dev @graphql-codegen/cli @graphql-codegen/fragment-matcher @graphql-codegen/schema-ast @graphql-codegen/typed-document-node @graphql-codegen/typescript @graphql-codegen/typescript-operations 46 | ``` 47 | 48 | 49 | 50 | 51 | ```bash 52 | yarn add @apollo/client graphql 53 | yarn add --dev @graphql-codegen/cli @graphql-codegen/fragment-matcher @graphql-codegen/schema-ast @graphql-codegen/typed-document-node @graphql-codegen/typescript @graphql-codegen/typescript-operations 54 | ``` 55 | 56 | 57 | 58 | ```` 59 | -------------------------------------------------------------------------------- /docs/docs/graphql-code-generator/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Graphql Code Generator", 3 | "position": 6 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/graphql-code-generator/graphql-codegen-preset.md: -------------------------------------------------------------------------------- 1 | # @kazekyo/nau-graphql-codegen-preset 2 | 3 | This preset extends your schema required to run Nau, modifies GraphQL documents you have written, and generates TypeScript Code. 4 | ```yaml 5 | schema: http://localhost:4000/graphql 6 | documents: 7 | - src/**/*.tsx 8 | generates: 9 | src/generated/graphql.ts: 10 | // highlight-start 11 | preset: '@kazekyo/nau-graphql-codegen-preset' 12 | presetConfig: 13 | generateTypeScriptCode: true 14 | // highlight-end 15 | plugins: 16 | - typescript 17 | - typescript-operations 18 | - typed-document-node 19 | src/generated/introspection-result.json: 20 | // highlight-next-line 21 | preset: '@kazekyo/nau-graphql-codegen-preset' 22 | plugins: 23 | - fragment-matcher 24 | ./schema.graphql: 25 | // highlight-next-line 26 | preset: '@kazekyo/nau-graphql-codegen-preset' 27 | plugins: 28 | - schema-ast 29 | ``` 30 | 31 | ## Preset Config 32 | 33 | ### generateTypeScriptCode 34 | type: `boolean` default: `false` 35 | 36 | If set to `true`, It generates TypeScript code. For example, `withCacheUpdater` is generated by enabling this option. The default is `false`. 37 | -------------------------------------------------------------------------------- /docs/docs/guides/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Guides", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/guides/ide-settings.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: IDE Settings 3 | --- 4 | 5 | 6 | ```mdx-code-block 7 | import Tabs from '@theme/Tabs'; 8 | import TabItem from '@theme/TabItem'; 9 | ``` 10 | 11 | 12 | Nau supports [Apollo Config](https://www.apollographql.com/docs/devtools/apollo-config/) and [GraphQL Config](https://www.graphql-config.com/). We are testing works with VS Code extensions [Apollo GraphQL Extension](https://marketplace.visualstudio.com/items?itemName=apollographql.vscode-apollo) and [GraphQL Extension](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql). 13 | 14 | `@kazekyo/nau-config` provides directives knowledge to your IDE. 15 | 16 | # Installation 17 | ````mdx-code-block 18 | 19 | 20 | 21 | ```bash 22 | npm install --save-dev @kazekyo/nau-config 23 | ``` 24 | 25 | 26 | 27 | 28 | ```bash 29 | yarn add --dev @kazekyo/nau-config 30 | ``` 31 | 32 | 33 | 34 | ```` 35 | 36 | 37 | # Apollo Config 38 | ```js title="apollo.config.js" 39 | // highlight-start 40 | const { apolloConfig } = require('@kazekyo/nau-config'); 41 | const config = apolloConfig.generateConfig(); 42 | // highlight-end 43 | module.exports = { 44 | client: { 45 | // highlight-next-line 46 | ...config.client, 47 | service: { 48 | name: 'example-app', 49 | url: 'http://localhost:4000/graphql', 50 | }, 51 | }, 52 | }; 53 | ``` 54 | 55 | See [here](https://www.apollographql.com/docs/devtools/editor-plugins/) for how to set up `apollo.config.js`. 56 | 57 | # GraphQL Config 58 | ```js title="graphql.config.js" 59 | // highlight-start 60 | const { graphqlConfig } = require('@kazekyo/nau-config'); 61 | // highlight-end 62 | module.exports = { 63 | // highlight-next-line 64 | schema: ['http://localhost:4000/graphql', graphqlConfig.clientSchemaPath], 65 | documents: 'src/**/*.{graphql,js,ts,jsx,tsx}', 66 | }; 67 | ``` 68 | 69 | See [here](https://www.graphql-config.com/docs/user/user-introduction) for how to set up `graphql.config.js`. 70 | -------------------------------------------------------------------------------- /docs/docs/guides/local-variables.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Local variables in Fragment 6 | 7 | We cannot define local variables in Fragment, so a variable name must be unique across all fragments used. And if we want to set a default value for a variable used in a fragment, we have to put it as a query variable. 8 | 9 | You can use @arguments directive and @argumentDefinitions directive to solve this issue. 10 | 11 | These can be used to create local variables for fragments. 12 | 13 | ```graphql 14 | query MyQuery($myName: String!) { 15 | foo { 16 | ...bar1 @arguments(name: $myName) 17 | ...bar2 @arguments(name: "your name") 18 | } 19 | } 20 | 21 | fragment bar1 on Bar @argumentDefinitions(name: { type: "String!" }) { 22 | field1(name: $name) { 23 | id 24 | } 25 | } 26 | 27 | fragment bar2 on Bar @argumentDefinitions(name: { type: "String!" }) { 28 | field2(name: $name) { 29 | id 30 | } 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/docs/guides/pagination.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Pagination 6 | When loading the next edges in a connection, you will get all data that has nothing to do with the connection if you reuse a query used to display the current page. But in most cases, it should be more efficient to request only the connection part. 7 | 8 | By using some directives and hooks, you can paginate with less code while making efficient API requests. 9 | 10 | First, create a fragment in the list component. Then, add some directives to that fragment. 11 | ```graphql 12 | fragment List_user on User 13 | // highlight-start 14 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 15 | @refetchable(queryName: "List_PaginationQuery") { 16 | // highlight-end 17 | id 18 | // highlight-next-line 19 | items(first: $count, after: $cursor) @pagination { 20 | edges { 21 | node { 22 | name 23 | } 24 | } 25 | } 26 | } 27 | ``` 28 | Attaching `@refetchable` to a fragment generates a query for refetching data using `node(id:)` in your backend API. The `@argumentDefinitions` are needed to define necessary variables when generating the refetch query. Attach `@pagination` to the connection field to indicate to Nau which part of the query is the connection to be paginated. 29 | 30 | Let's use the generated refetch query. 31 | 32 | ```tsx 33 | const { nodes, hasNext, loadNext, isLoading } = usePagination(List_PaginationQueryDocument, { 34 | id: user.id, 35 | connection: user.items, 36 | }); 37 | ``` 38 | Nau generates the refetch query with the name specified in the `queryName` argument of `@refetchable`. You can use it with `usePagination`. 39 | 40 | The `id` argument of `usePagination` will be the `id` specified in `node(id:)`. The `connection` argument is the field to which `@pagination` is attached. 41 | 42 | 43 | You can use return values of `usePagination` to display data and place a "read more" button. 44 | 45 | ```tsx title="src/List.tsx" 46 | import { usePagination } from '@kazekyo/nau'; 47 | import { List_PaginationQueryDocument, List_UserFragment } from './generated/graphql'; 48 | 49 | gql` /* GraphQL */ 50 | fragment List_user on User 51 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 52 | @refetchable(queryName: "List_PaginationQuery") { 53 | id 54 | items(first: $count, after: $cursor) @pagination { 55 | edges { 56 | node { 57 | name 58 | } 59 | } 60 | } 61 | } 62 | `; 63 | 64 | const List: React.FC<{ user: List_UserFragment; }> = ({ user }) => { 65 | const { nodes, hasNext, loadNext, isLoading } = usePagination(List_PaginationQueryDocument, { 66 | id: user.id, 67 | connection: user.items, 68 | }); 69 | 70 | return ( 71 | <> 72 |
73 | {nodes.map((node, i) => { 74 | return ( 75 |
{node.name}
76 | ); 77 | })} 78 |
79 | {hasNext && ( 80 | 87 | )} 88 | 89 | ); 90 | }; 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/docs/guides/relay-spec.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 0 3 | --- 4 | 5 | # About Relay GraphQL Server Specification 6 | [Relay GraphQL Server Specification](https://relay.dev/docs/guides/graphql-server-specification) is a specification of GraphQL Server that makes GraphQL more effective. 7 | 8 | Large parts of the specification are recognized as best practices in GraphQL. 9 | 10 | - [Global Object Identification](https://graphql.org/learn/global-object-identification/) 11 | - [Pagination](https://graphql.org/learn/pagination/) 12 | 13 | Nau does not implement the specification on your server. Nau helps you develop frontend applications efficiently when you use a server that is compliant with the specification. 14 | -------------------------------------------------------------------------------- /docs/docs/guides/updating-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Updating Data 6 | 7 | If you want to add or remove an element from a list, you can easily implement it with some directives. 8 | 9 | ## Adding Data 10 | `@prependNode` adds a element to the top of a list, and `@appendNode` adds a element to the bottom of a list. 11 | 12 | ```graphql 13 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!!) { 14 | addItem(input: $input) { 15 | item @prependNode(connections: $connections) { 16 | name 17 | } 18 | } 19 | } 20 | ``` 21 | 22 | And you must have attached `@pagination` to the connection field for the following. 23 | ```graphql 24 | fragment List_user on User { 25 | id 26 | items(first: $count, after: $cursor) @pagination { 27 | edges { 28 | node { 29 | name 30 | } 31 | } 32 | } 33 | } 34 | ``` 35 | If you attached `@pagination`, Nau will generate a `_connectionId`. You must specify this `_connectionId` to the `connections` argument of `@prependNode` in order for Nau to guess which connection to add the element to. 36 | 37 | Run `yarn graphql-codegen` to generate documents and use `_connectionId`. 38 | 39 | ```tsx title="src/List.tsx" 40 | const List: React.FC<{ user: List_UserFragment }> = ({ user }) => { 41 | // ... 42 | const [addItem] = useMutation(AddItemMutationDocument); 43 | // ... 44 | return ( 45 | <> 46 | 58 | {/*...*/} 59 | 60 | ) 61 | } 62 | ``` 63 | 64 | ## Removing data 65 | You can also delete data. `@deleteRecord` removes data from the cache based on the id contained in a mutation result. It will also automatically remove edges associated with the data from all connections to which `@pagionation` is attached. 66 | 67 | ```graphql 68 | mutation RemoveItemMutation($input: RemoveItemInput!) { 69 | removeItem(input: $input) { 70 | removedItem { 71 | id @deleteRecord(typename: "Item") 72 | } 73 | } 74 | } 75 | ``` 76 | You have to attach the `@deleteRecord` directive only, and no other work is required. Nau will automatically manipulate the cache based on the mutation result. 77 | 78 | 79 | ## Subscription 80 | `@appendNode`/`@appendNode` and `@deleteRecord` also work the same with Subscription as they do with Mutation. 81 | 82 | ```graphql 83 | subscription ItemAddedSubscription($connections: [String!]!) { 84 | itemAdded { 85 | item @prependNode(connections: $connections) { 86 | ...ListItem_item 87 | } 88 | } 89 | } 90 | subscription ItemRemovedSubscription { 91 | itemRemoved { 92 | id @deleteRecord(typename: "Item") 93 | } 94 | } 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Introduction 6 | 7 | Nau is a tool that makes [Apollo Client](https://github.com/apollographql/apollo-client) more productive for users using [Relay GraphQL Server Specification](https://relay.dev/docs/guides/graphql-server-specification) compliant backends. 8 | 9 | - Make cache operations very easy 10 | - Provide custom directives to write declaratively and improve productivity 11 | - Support co-location of components and fragments by allowing a query to split into the fragments 12 | - Support subscriptions 13 | 14 | The tool aims to help frontend developers build frontend applications more quickly, with fewer bugs, and more efficiently. 15 | 16 | Nau is inspired by [Relay](https://relay.dev/). However, our goal is not to create a full copy of Relay on Apollo Client. The goal is to make Apollo Client more powerful by integrating Relay GraphQL Server Specification. 17 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 5 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: 'Nau', 10 | tagline: 'Build your frontend apps safely and quickly with Apollo Client', 11 | url: 'https://www.naugraphql.com', 12 | baseUrl: '/', 13 | onBrokenLinks: 'throw', 14 | onBrokenMarkdownLinks: 'warn', 15 | favicon: 'img/favicon.ico', 16 | organizationName: 'kazekyo', 17 | projectName: 'nau', 18 | 19 | presets: [ 20 | [ 21 | 'classic', 22 | /** @type {import('@docusaurus/preset-classic').Options} */ 23 | ({ 24 | docs: { 25 | sidebarPath: require.resolve('./sidebars.js'), 26 | editUrl: 'https://github.com/kazekyo/nau/tree/main/docs/', 27 | }, 28 | theme: { 29 | customCss: require.resolve('./src/css/custom.css'), 30 | }, 31 | gtag: { 32 | trackingID: 'G-2XPHLCBQE5', 33 | }, 34 | }), 35 | ], 36 | ], 37 | 38 | themeConfig: 39 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 40 | ({ 41 | navbar: { 42 | title: 'Nau', 43 | logo: { 44 | alt: 'Nau Logo', 45 | src: 'img/logo.svg', 46 | }, 47 | items: [ 48 | { 49 | type: 'doc', 50 | docId: 'introduction', 51 | position: 'right', 52 | label: 'Docs', 53 | }, 54 | { 55 | href: 'https://github.com/kazekyo/nau', 56 | label: 'GitHub', 57 | position: 'right', 58 | }, 59 | ], 60 | }, 61 | footer: { 62 | style: 'dark', 63 | links: [ 64 | { 65 | title: 'Docs', 66 | items: [ 67 | { 68 | label: 'Docs', 69 | to: '/docs/introduction', 70 | }, 71 | ], 72 | }, 73 | { 74 | title: 'More', 75 | items: [ 76 | { 77 | label: 'GitHub', 78 | href: 'https://github.com/kazekyo/nau', 79 | }, 80 | ], 81 | }, 82 | ], 83 | copyright: `Copyright © ${new Date().getFullYear()} Kyohei Nakamori. All rights reserved.`, 84 | }, 85 | prism: { 86 | theme: lightCodeTheme, 87 | darkTheme: darkCodeTheme, 88 | }, 89 | }), 90 | }; 91 | 92 | module.exports = config; 93 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^2.0.0", 19 | "@docusaurus/preset-classic": "^2.0.0", 20 | "@mdx-js/react": "^1.6.22", 21 | "clsx": "^1.1.1", 22 | "prism-react-renderer": "^1.3.1", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "^2.0.0", 28 | "@tsconfig/docusaurus": "^1.0.5", 29 | "typescript": "^4.6.3" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.5%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['hello'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | module.exports = sidebars; 32 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './styles.module.css'; 4 | 5 | type FeatureItem = { 6 | title: string; 7 | description: JSX.Element; 8 | }; 9 | 10 | const FeatureList: FeatureItem[] = [ 11 | { 12 | title: 'Safe and quick', 13 | description: ( 14 | <> 15 | Nau simplifies cache operations in the Apollo Client and reduces bugs caused by human inattention. Moreover, you 16 | can write code safely in TypeScript. 17 | 18 | ), 19 | }, 20 | { 21 | title: 'Working on your behalf', 22 | description: ( 23 | <> 24 | Nau will guess what you need from the little bit of code you write and automatically do the additional work 25 | required on your behalf. 26 | 27 | ), 28 | }, 29 | { 30 | title: 'Many useful directives', 31 | description: <>Using convenient client directives can help you be highly productive., 32 | }, 33 | ]; 34 | 35 | function Feature({ title, description }: FeatureItem) { 36 | return ( 37 |
38 |
39 |

{title}

40 |

{description}

41 |
42 |
43 | ); 44 | } 45 | 46 | export default function HomepageFeatures(): JSX.Element { 47 | return ( 48 |
49 |
50 |
51 | {FeatureList.map((props, idx) => ( 52 | 53 | ))} 54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | margin-top: 2rem; 6 | width: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #38B2AC; 10 | /* --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; */ 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: #E8EDF2; 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #4FD1C5; 23 | --docusaurus-highlighted-code-line-bg: #363949; 24 | /* --ifm-color-primary-dark: #21af90; 25 | --ifm-color-primary-darker: #1fa588; 26 | --ifm-color-primary-darkest: #1a8870; 27 | --ifm-color-primary-light: #29d5b0; 28 | --ifm-color-primary-lighter: #32d8b4; 29 | --ifm-color-primary-lightest: #4fddbf; */ 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .getStartedButton { 26 | margin-right: 0.5rem; 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import Layout from '@theme/Layout'; 4 | import Link from '@docusaurus/Link'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import styles from './index.module.css'; 7 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 8 | 9 | function HomepageHeader() { 10 | const { siteConfig } = useDocusaurusContext(); 11 | return ( 12 |
13 |
14 |

{siteConfig.title}

15 |

{siteConfig.tagline}

16 |
17 | 21 | Get Started 22 | 23 | 24 | Github 25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default function Home(): JSX.Element { 33 | return ( 34 | 35 | 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazekyo/nau/9bf179e12f353e4bcec87c08f2a0e28c654cb763/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazekyo/nau/9bf179e12f353e4bcec87c08f2a0e28c654cb763/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/app/.env.development: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/app/.prettierignore: -------------------------------------------------------------------------------- 1 | src/generated 2 | -------------------------------------------------------------------------------- /examples/app/README.md: -------------------------------------------------------------------------------- 1 | 1. `yarn` 2 | 2. Start backend : `yarn run start:server` 3 | 3. Start frontend : `yarn run start:frontend` 4 | 4. Visit http://localhost:3001 5 | 6 | -------------------------------------------------------------------------------- /examples/app/apollo.config.js: -------------------------------------------------------------------------------- 1 | const { apolloConfig } = require('@kazekyo/nau-config'); 2 | 3 | const config = apolloConfig.generateConfig(); 4 | module.exports = { 5 | client: { 6 | ...config.client, 7 | service: { 8 | name: 'example-app', 9 | url: 'http://localhost:4000/graphql', 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /examples/app/codegen.yml: -------------------------------------------------------------------------------- 1 | schema: http://localhost:4000/graphql 2 | generates: 3 | src/generated/graphql.ts: 4 | preset: '@kazekyo/nau-graphql-codegen-preset' 5 | presetConfig: 6 | generateTypeScriptCode: true 7 | documents: 8 | - src/**/*.tsx 9 | - src/**/*.graphql 10 | plugins: 11 | - typescript 12 | - typescript-operations 13 | - typed-document-node 14 | src/generated/introspection-result.json: 15 | plugins: 16 | - fragment-matcher 17 | ./schema.graphql: 18 | plugins: 19 | - schema-ast 20 | -------------------------------------------------------------------------------- /examples/app/graphql.config.js: -------------------------------------------------------------------------------- 1 | const { graphqlConfig } = require('@kazekyo/nau-config'); 2 | 3 | module.exports = { 4 | schema: ['http://localhost:4000/graphql', graphqlConfig.clientSchemaPath], 5 | documents: 'src/**/*.{graphql,js,ts,jsx,tsx}', 6 | }; 7 | -------------------------------------------------------------------------------- /examples/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-app", 3 | "version": "0.4.5", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/client": "^3.6.2", 7 | "@babel/cli": "^7.12.13", 8 | "@babel/core": "^7.12.13", 9 | "@babel/plugin-proposal-class-properties": "^7.12.13", 10 | "@babel/preset-env": "^7.12.13", 11 | "@babel/preset-typescript": "^7.12.13", 12 | "@chakra-ui/icons": "^2.0.0", 13 | "@chakra-ui/react": "^2.0.0", 14 | "@emotion/react": "^11", 15 | "@emotion/styled": "^11", 16 | "@kazekyo/nau": "^0.4.4", 17 | "@types/express": "^4.17.13", 18 | "@types/node": "^18.0.0", 19 | "@types/react": "^18.0.8", 20 | "@types/react-dom": "^18.0.3", 21 | "apollo-server-core": "^3.7.0", 22 | "apollo-server-express": "^3.7.0", 23 | "express": "^4.17.1", 24 | "framer-motion": "^9.0.0", 25 | "graphql": "^16.4.0", 26 | "graphql-relay": "^0.10.0", 27 | "graphql-subscriptions": "^2.0.0", 28 | "graphql-ws": "^5.8.1", 29 | "prettier": "^2.2.1", 30 | "react": "^18.1.0", 31 | "react-dom": "^18.1.0", 32 | "react-scripts": "5.0.1", 33 | "web-vitals": "^3.0.1", 34 | "ws": "^8.6.0" 35 | }, 36 | "scripts": { 37 | "start:frontend": "PORT=3001 react-scripts start", 38 | "start:server": "tsc --project server-tsconfig.json && node dist/server/index.js", 39 | "start": "yarn start:server & yarn start:frontend", 40 | "build": "react-scripts build", 41 | "eject": "react-scripts eject", 42 | "codegen": "graphql-codegen --config codegen.yml" 43 | }, 44 | "browserslist": { 45 | "production": [ 46 | ">0.2%", 47 | "not dead", 48 | "not op_mini all" 49 | ], 50 | "development": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "@graphql-codegen/cli": "^3.0.0", 58 | "@graphql-codegen/fragment-matcher": "^4.0.0", 59 | "@graphql-codegen/schema-ast": "^3.0.0", 60 | "@graphql-codegen/typed-document-node": "^3.0.0", 61 | "@graphql-codegen/typescript": "^3.0.0", 62 | "@graphql-codegen/typescript-operations": "^3.0.0", 63 | "@kazekyo/nau-config": "^0.4.4", 64 | "@kazekyo/nau-graphql-codegen-preset": "^0.4.5", 65 | "typescript": "^4.6.4" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazekyo/nau/9bf179e12f353e4bcec87c08f2a0e28c654cb763/examples/app/public/favicon.ico -------------------------------------------------------------------------------- /examples/app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Example 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazekyo/nau/9bf179e12f353e4bcec87c08f2a0e28c654cb763/examples/app/public/logo192.png -------------------------------------------------------------------------------- /examples/app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazekyo/nau/9bf179e12f353e4bcec87c08f2a0e28c654cb763/examples/app/public/logo512.png -------------------------------------------------------------------------------- /examples/app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.itemstxt.org/itemstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/app/schema.graphql: -------------------------------------------------------------------------------- 1 | input AddItemInput { 2 | clientMutationId: String 3 | itemName: String! 4 | userId: ID! 5 | } 6 | 7 | type AddItemPayload { 8 | clientMutationId: String 9 | item: Item! 10 | } 11 | 12 | type Item implements Node { 13 | """The ID of an object""" 14 | id: ID! 15 | 16 | """The name of the item.""" 17 | name: String! 18 | } 19 | 20 | type ItemAddedPayload { 21 | item: Item! 22 | } 23 | 24 | """A connection to a list of items.""" 25 | type ItemConnection { 26 | """A list of edges.""" 27 | edges: [ItemEdge] 28 | 29 | """Information to aid in pagination.""" 30 | pageInfo: PageInfo! 31 | } 32 | 33 | """An edge in a connection.""" 34 | type ItemEdge { 35 | """A cursor for use in pagination""" 36 | cursor: String! 37 | 38 | """The item at the end of the edge""" 39 | node: Item 40 | } 41 | 42 | type ItemRemovedPayload { 43 | """The ID of an object""" 44 | id: ID! 45 | } 46 | 47 | type Mutation { 48 | addItem(input: AddItemInput!): AddItemPayload 49 | removeItem(input: RemoveItemInput!): RemoveItemPayload 50 | updateItem(input: UpdateItemInput!): UpdateItemPayload 51 | } 52 | 53 | """An object with an ID""" 54 | interface Node { 55 | """The id of the object.""" 56 | id: ID! 57 | } 58 | 59 | """Information about pagination in a connection.""" 60 | type PageInfo { 61 | """When paginating forwards, the cursor to continue.""" 62 | endCursor: String 63 | 64 | """When paginating forwards, are there more items?""" 65 | hasNextPage: Boolean! 66 | 67 | """When paginating backwards, are there more items?""" 68 | hasPreviousPage: Boolean! 69 | 70 | """When paginating backwards, the cursor to continue.""" 71 | startCursor: String 72 | } 73 | 74 | type Query { 75 | items( 76 | """Returns the items in the list that come after the specified cursor.""" 77 | after: String 78 | 79 | """Returns the items in the list that come before the specified cursor.""" 80 | before: String 81 | 82 | """Returns the first n items from the list.""" 83 | first: Int 84 | keyword: String 85 | 86 | """Returns the last n items from the list.""" 87 | last: Int 88 | ): ItemConnection! 89 | 90 | """Fetches an object given its ID""" 91 | node( 92 | """The ID of an object""" 93 | id: ID! 94 | ): Node 95 | viewer: User 96 | } 97 | 98 | input RemoveItemInput { 99 | clientMutationId: String 100 | itemId: String! 101 | userId: ID! 102 | } 103 | 104 | type RemoveItemPayload { 105 | clientMutationId: String 106 | removedItem: RemovedItem! 107 | } 108 | 109 | type RemovedItem { 110 | """The ID of an object""" 111 | id: ID! 112 | } 113 | 114 | type Subscription { 115 | itemAdded: ItemAddedPayload! 116 | itemRemoved: ItemRemovedPayload! 117 | } 118 | 119 | input UpdateItemInput { 120 | clientMutationId: String 121 | itemId: ID! 122 | newItemName: String! 123 | } 124 | 125 | type UpdateItemPayload { 126 | clientMutationId: String 127 | item: Item! 128 | } 129 | 130 | type User implements Node { 131 | """The ID of an object""" 132 | id: ID! 133 | items( 134 | """Returns the items in the list that come after the specified cursor.""" 135 | after: String 136 | 137 | """Returns the items in the list that come before the specified cursor.""" 138 | before: String 139 | 140 | """Returns the first n items from the list.""" 141 | first: Int 142 | keyword: String 143 | 144 | """Returns the last n items from the list.""" 145 | last: Int 146 | ): ItemConnection! 147 | 148 | """The name of the user.""" 149 | name: String 150 | } -------------------------------------------------------------------------------- /examples/app/server-tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "lib": ["es2019"], 6 | "module": "commonjs", 7 | "target": "ES2017", 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["src/server/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /examples/app/src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazekyo/nau/9bf179e12f353e4bcec87c08f2a0e28c654cb763/examples/app/src/App.css -------------------------------------------------------------------------------- /examples/app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { gql, useQuery } from '@apollo/client'; 2 | import { Box, Divider } from '@chakra-ui/react'; 3 | import React from 'react'; 4 | import './App.css'; 5 | import { AppQueryDocument } from './generated/graphql'; 6 | import List from './List'; 7 | 8 | gql` 9 | query AppQuery { 10 | viewer { 11 | id 12 | ...List_user 13 | } 14 | } 15 | `; 16 | 17 | const App: React.FC = () => { 18 | const { loading, error, data } = useQuery(AppQueryDocument); 19 | 20 | if (loading || !data) return

Loading...

; 21 | if (error) return

Error :(

; 22 | 23 | return ( 24 |
25 | 26 | 27 | 28 | My items 29 | 30 | 31 | User ID: {data.viewer?.id} 32 | 33 | 34 | 35 | {data.viewer && } 36 | 37 |
38 | ); 39 | }; 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /examples/app/src/List.tsx: -------------------------------------------------------------------------------- 1 | import { gql, useMutation, useSubscription } from '@apollo/client'; 2 | import { AddIcon } from '@chakra-ui/icons'; 3 | import { Box, Button } from '@chakra-ui/react'; 4 | import { usePagination } from '@kazekyo/nau'; 5 | import * as React from 'react'; 6 | import { 7 | AddItemMutationDocument, 8 | ItemAddedSubscriptionDocument, 9 | ItemRemovedSubscriptionDocument, 10 | List_PaginationQueryDocument, 11 | List_UserFragment, 12 | } from './generated/graphql'; 13 | import ListItem from './ListItem'; 14 | 15 | gql` 16 | fragment List_user on User 17 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 18 | @refetchable(queryName: "List_PaginationQuery") { 19 | items(first: $count, after: $cursor) @pagination { 20 | edges { 21 | node { 22 | ...ListItem_item 23 | } 24 | } 25 | } 26 | ...ListItem_user 27 | } 28 | 29 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 30 | addItem(input: $input) { 31 | item @prependNode(connections: $connections) { 32 | ...ListItem_item 33 | } 34 | } 35 | } 36 | 37 | subscription ItemAddedSubscription($connections: [String!]!) { 38 | itemAdded { 39 | item @prependNode(connections: $connections) { 40 | ...ListItem_item 41 | } 42 | } 43 | } 44 | 45 | subscription ItemRemovedSubscription { 46 | itemRemoved { 47 | id @deleteRecord(typename: "Item") 48 | } 49 | } 50 | `; 51 | 52 | const List: React.FC<{ user: List_UserFragment }> = ({ user }) => { 53 | useSubscription(ItemAddedSubscriptionDocument, { 54 | variables: { 55 | connections: [user.items._connectionId], 56 | }, 57 | }); 58 | useSubscription(ItemRemovedSubscriptionDocument); 59 | const [addItem] = useMutation(AddItemMutationDocument); 60 | const { nodes, hasNext, loadNext, isLoading } = usePagination(List_PaginationQueryDocument, { 61 | id: user.id, 62 | connection: user.items, 63 | }); 64 | 65 | return ( 66 | <> 67 |
68 | 81 |
82 |
83 | {nodes.map((node) => { 84 | return ( 85 | 86 | 87 | 88 | ); 89 | })} 90 |
91 | {hasNext && ( 92 | 102 | )} 103 | 104 | ); 105 | }; 106 | 107 | export default List; 108 | -------------------------------------------------------------------------------- /examples/app/src/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import { gql, useMutation } from '@apollo/client'; 2 | import { DeleteIcon } from '@chakra-ui/icons'; 3 | import { Box, Button, Spacer } from '@chakra-ui/react'; 4 | import * as React from 'react'; 5 | import { FC } from 'react'; 6 | import { ListItem_ItemFragment, ListItem_UserFragment, RemoveItemMutationDocument } from './generated/graphql'; 7 | 8 | gql` 9 | fragment ListItem_user on User { 10 | id 11 | } 12 | 13 | fragment ListItem_item on Item { 14 | id 15 | name 16 | } 17 | 18 | mutation RemoveItemMutation($input: RemoveItemInput!) { 19 | removeItem(input: $input) { 20 | removedItem { 21 | id @deleteRecord(typename: "Item") 22 | } 23 | } 24 | } 25 | `; 26 | 27 | const ListItem: FC<{ 28 | user: ListItem_UserFragment; 29 | item: ListItem_ItemFragment; 30 | }> = ({ user, item }) => { 31 | const [removeItem] = useMutation(RemoveItemMutationDocument); 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | {item.name} 39 | 40 | 41 | Item ID: {item.id} 42 | 43 | 44 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default ListItem; 60 | -------------------------------------------------------------------------------- /examples/app/src/generated/introspection-result.json: -------------------------------------------------------------------------------- 1 | { 2 | "possibleTypes": { 3 | "Node": [ 4 | "Item", 5 | "User" 6 | ] 7 | } 8 | } -------------------------------------------------------------------------------- /examples/app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Itemo', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 4 | 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | code { 10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 11 | } 12 | -------------------------------------------------------------------------------- /examples/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloClient, ApolloProvider, from, HttpLink, InMemoryCache, PossibleTypesMap, split } from '@apollo/client'; 2 | import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; 3 | import { relayStylePagination } from '@apollo/client/utilities'; 4 | import { ChakraProvider } from '@chakra-ui/react'; 5 | import { createCacheUpdaterLink, isSubscriptionOperation } from '@kazekyo/nau'; 6 | import { createClient } from 'graphql-ws'; 7 | import React from 'react'; 8 | import { createRoot } from 'react-dom/client'; 9 | import App from './App'; 10 | import { withCacheUpdater } from './generated/graphql'; 11 | import introspection from './generated/introspection-result.json'; 12 | import './index.css'; 13 | const introspectionResult = introspection as { possibleTypes: PossibleTypesMap }; 14 | 15 | const wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/subscription' })); 16 | const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); 17 | const cacheUpdaterLink = createCacheUpdaterLink(); 18 | const splitLink = split( 19 | ({ query }) => isSubscriptionOperation(query), 20 | from([cacheUpdaterLink, wsLink]), 21 | from([cacheUpdaterLink, httpLink]), 22 | ); 23 | 24 | const client = new ApolloClient({ 25 | cache: new InMemoryCache({ 26 | addTypename: true, 27 | possibleTypes: introspectionResult.possibleTypes, 28 | typePolicies: withCacheUpdater({ 29 | User: { 30 | fields: { 31 | items: relayStylePagination(), 32 | }, 33 | }, 34 | }), 35 | }), 36 | link: splitLink, 37 | }); 38 | 39 | const container = document.getElementById('root'); 40 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 41 | const root = createRoot(container!); 42 | root.render( 43 | 44 | 45 | 46 | 47 | 48 | 49 | , 50 | ); 51 | -------------------------------------------------------------------------------- /examples/app/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/setupJest.js'], 3 | testEnvironment: 'jsdom', 4 | collectCoverageFrom: ['packages/**/*.{ts,tsx}'], 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 6 | modulePathIgnorePatterns: ['/examples'], 7 | testMatch: ['**/?(*.)+(spec|test).+(ts|tsx|js)'], 8 | transform: { 9 | '^.+\\.(ts|tsx)$': 'ts-jest', 10 | }, 11 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'], 12 | globals: { 13 | 'ts-jest': { 14 | tsconfig: 'tsconfig.json', 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "0.4.5", 4 | "npmClient": "yarn", 5 | "useWorkspaces": true, 6 | "registry": "https://registry.npmjs.org/" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nau", 3 | "private": true, 4 | "workspaces": { 5 | "packages": [ 6 | "packages/*", 7 | "examples/*" 8 | ] 9 | }, 10 | "devDependencies": { 11 | "@babel/cli": "^7.12.13", 12 | "@babel/core": "^7.12.13", 13 | "@babel/plugin-proposal-class-properties": "^7.12.13", 14 | "@babel/preset-env": "^7.12.13", 15 | "@babel/preset-typescript": "^7.12.13", 16 | "@testing-library/jest-dom": "^5.14.1", 17 | "@testing-library/react": "^13.2.0", 18 | "@types/jest": "^28.1.0", 19 | "@typescript-eslint/eslint-plugin": "^5.23.0", 20 | "@typescript-eslint/parser": "^5.23.0", 21 | "babel-jest": "^29.0.2", 22 | "eslint": "^8.15.0", 23 | "eslint-config-prettier": "^8.3.0", 24 | "eslint-plugin-babel": "5.3.1", 25 | "eslint-plugin-prettier": "^4.0.0", 26 | "graphql": "^16.4.0", 27 | "jest": "^28.1.0", 28 | "jest-environment-jsdom": "^29.0.3", 29 | "lerna": "^6.0.0", 30 | "prettier": "^2.2.1", 31 | "ts-jest": "^28.0.2", 32 | "typescript": "^4.6.4" 33 | }, 34 | "scripts": { 35 | "prebuild": "yarn lerna exec -- rimraf dist --ignore=example-app", 36 | "build": "yarn lerna exec -- yarn build --ignore=example-app", 37 | "test": "jest", 38 | "lint": "eslint \"packages/**/src/**/*.{js,jsx,ts,tsx}\"", 39 | "start:docs": "cd docs && yarn start", 40 | "build:docs": "cd docs && yarn build" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/config/README.md: -------------------------------------------------------------------------------- 1 | # @kazekyo/nau 2 | 3 | > Configuration for [Nau](https://github.com/kazekyo/nau) 4 | 5 | See the [documentation](https://www.naugraphql.com/docs/introduction) for more information. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | yarn add -D @kazekyo/nau-config 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kazekyo/nau-config", 3 | "version": "0.4.4", 4 | "description": "Configuration for Nau", 5 | "author": "kazekyo", 6 | "bugs": { 7 | "url": "https://github.com/kazekyo/nau/issues" 8 | }, 9 | "dependencies": { 10 | "@relay-graphql-js/validation-rules": "^0.1.0" 11 | }, 12 | "peerDependencies": { 13 | "graphql": "^15.6.1" 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "homepage": "https://www.naugraphql.com", 19 | "keywords": [ 20 | "apollo", 21 | "client", 22 | "graphql", 23 | "nau", 24 | "directive", 25 | "config", 26 | "configuration" 27 | ], 28 | "license": "MIT", 29 | "main": "dist/index.js", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/kazekyo/nau.git" 33 | }, 34 | "scripts": { 35 | "test": "jest", 36 | "lint": "eslint src", 37 | "generate:client-schema": "node scripts/generateClientSchema.js", 38 | "build": "yarn run build:types && yarn run build:js && yarn run generate:client-schema", 39 | "build:types": "tsc --build tsconfig.build.json", 40 | "build:js": "babel src --out-dir dist --root-mode upward --extensions \".ts,.tsx\" --source-maps inline --ignore \"**/__tests__/\",\"**/*.test.ts\",\"example\",\"**/testing/\"" 41 | }, 42 | "types": "dist/index.d.ts", 43 | "publishConfig": { 44 | "access": "public", 45 | "registry": "https://registry.npmjs.org/" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/config/scripts/generateClientSchema.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const clientSchema = require('../dist/clientSchema'); 3 | 4 | const apolloConfigClientSchema = clientSchema.apolloConfigClientSchema(); 5 | const graphQLConfigClientSchema = clientSchema.graphQLConfigClientSchema(); 6 | 7 | fs.writeFileSync(clientSchema.apolloConfigClientSchemaPath, apolloConfigClientSchema); 8 | fs.writeFileSync(clientSchema.graphqlConfigClientSchemaPath, graphQLConfigClientSchema); 9 | -------------------------------------------------------------------------------- /packages/config/src/__tests__/clientSchema.test.ts: -------------------------------------------------------------------------------- 1 | import { apolloConfigClientSchema, graphQLConfigClientSchema } from '../clientSchema'; 2 | 3 | describe('apolloClientSchema', () => { 4 | it('returns a client schema for apollo.config.js', () => { 5 | expect(apolloConfigClientSchema()).toBe(`directive @arguments on FRAGMENT_SPREAD 6 | directive @argumentDefinitions on FRAGMENT_DEFINITION 7 | directive @refetchable(queryName: String!) on FRAGMENT_DEFINITION 8 | directive @pagination on FIELD 9 | directive @appendNode(connections: [String!]) on FIELD 10 | directive @prependNode(connections: [String!]) on FIELD 11 | directive @deleteRecord(typename: String!) on FIELD 12 | directive @client on FIELD`); 13 | }); 14 | }); 15 | 16 | describe('graphQLConfigClientSchema', () => { 17 | it('returns a client schema for graphql.config.js', () => { 18 | expect(graphQLConfigClientSchema()).toBe(`directive @refetchable(queryName: String!) on FRAGMENT_DEFINITION 19 | directive @pagination on FIELD 20 | directive @appendNode(connections: [String!]) on FIELD 21 | directive @prependNode(connections: [String!]) on FIELD 22 | directive @deleteRecord(typename: String!) on FIELD 23 | directive @client on FIELD`); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/config/src/apolloConfig.ts: -------------------------------------------------------------------------------- 1 | import { ValidationRule } from 'graphql'; 2 | import { loadDefaultValidationRules } from './dependencies/apollo'; 3 | import { apolloConfigClientSchemaPath } from './clientSchema'; 4 | import { validationRules } from './validationRules'; 5 | 6 | const ignored = [ 7 | 'NoUnusedFragmentsRule', 8 | 'NoUnusedVariablesRule', 9 | 'KnownArgumentNamesRule', 10 | 'NoUndefinedVariablesRule', 11 | ]; 12 | 13 | export const generateValidationRulesForApolloConfig = (): ValidationRule[] => { 14 | const defaultValidationRules = loadDefaultValidationRules(); 15 | return [ 16 | ...defaultValidationRules.filter((f) => !ignored.includes(f.name)), 17 | ...validationRules, 18 | ] as unknown[] as ValidationRule[]; 19 | }; 20 | 21 | export const generateApolloConfig = () => { 22 | return { 23 | client: { 24 | includes: ['src/**/*.{ts,tsx,js,jsx,graphql,gql}', apolloConfigClientSchemaPath], 25 | validationRules: generateValidationRulesForApolloConfig(), 26 | }, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/config/src/clientDirective.ts: -------------------------------------------------------------------------------- 1 | export const clientDirectives = { 2 | arguments: 'directive @arguments on FRAGMENT_SPREAD', 3 | argumentDefinitions: 'directive @argumentDefinitions on FRAGMENT_DEFINITION', 4 | refetchable: 'directive @refetchable(queryName: String!) on FRAGMENT_DEFINITION', 5 | pagination: 'directive @pagination on FIELD', 6 | appendNode: 'directive @appendNode(connections: [String!]) on FIELD', 7 | prependNode: 'directive @prependNode(connections: [String!]) on FIELD', 8 | deleteRecord: 'directive @deleteRecord(typename: String!) on FIELD', 9 | client: 'directive @client on FIELD', 10 | }; 11 | -------------------------------------------------------------------------------- /packages/config/src/clientSchema.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { clientDirectives } from './clientDirective'; 3 | 4 | export const apolloConfigClientSchemaPath = path.join(__dirname, `apollo.graphql`); 5 | export const graphqlConfigClientSchemaPath = path.join(__dirname, `graphql-config.graphql`); 6 | 7 | export const graphQLConfigClientSchema = (): string => { 8 | return generateClientSchemaString({ exclude: ['arguments', 'argumentDefinitions'] }); 9 | }; 10 | 11 | export const apolloConfigClientSchema = (): string => { 12 | return generateClientSchemaString({ exclude: [] }); 13 | }; 14 | 15 | const generateClientSchemaString = ({ exclude }: { exclude: string[] }): string => { 16 | const directives = Object.entries(clientDirectives) 17 | .filter(([key]) => !exclude.includes(key)) 18 | .map(([_, str]) => str); 19 | return directives.join('\n'); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/config/src/dependencies/apollo.ts: -------------------------------------------------------------------------------- 1 | import * as _GraphQL from 'graphql'; 2 | 3 | const mod = require.main || module; 4 | const isJest = typeof jest !== 'undefined'; 5 | 6 | // NOTE: By not loading dependencies until this function is called, 7 | // we can use this library in extensions without './errors/validation' 8 | export const loadDefaultValidationRules = () => { 9 | let validationRules: _GraphQL.ValidationRule[] = []; 10 | if (!isJest) { 11 | const { defaultValidationRules } = mod.require('./errors/validation') as { 12 | defaultValidationRules: _GraphQL.ValidationRule[]; 13 | }; 14 | validationRules = defaultValidationRules; 15 | } 16 | return validationRules; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/config/src/dependencies/graphql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import * as _GraphQL from 'graphql'; 3 | const mod = require.main || module; 4 | 5 | let GraphQLModule; 6 | try { 7 | GraphQLModule = mod.require('graphql'); 8 | } catch { 9 | GraphQLModule = _GraphQL; 10 | } 11 | 12 | export const { 13 | BREAK, 14 | GraphQLError, 15 | parseType, 16 | visit, 17 | isNonNullType, 18 | valueFromAST, 19 | isTypeSubTypeOf, 20 | getNullableType, 21 | typeFromAST, 22 | GraphQLNonNull, 23 | GraphQLObjectType, 24 | visitWithTypeInfo, 25 | isInputType, 26 | TypeInfo, 27 | isObjectType, 28 | } = GraphQLModule as typeof _GraphQL; 29 | -------------------------------------------------------------------------------- /packages/config/src/index.ts: -------------------------------------------------------------------------------- 1 | import { apolloConfigClientSchemaPath, graphqlConfigClientSchemaPath } from './clientSchema'; 2 | import { generateApolloConfig } from './apolloConfig'; 3 | 4 | export * from './validationRules'; 5 | export * from './clientDirective'; 6 | 7 | export const graphqlConfig = { 8 | clientSchemaPath: graphqlConfigClientSchemaPath, 9 | }; 10 | 11 | export const apolloConfig = { 12 | generateConfig: generateApolloConfig, 13 | clientSchemaPath: apolloConfigClientSchemaPath, 14 | }; 15 | -------------------------------------------------------------------------------- /packages/config/src/validationRules/__tests__/paginationDirective.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { buildSchema, GraphQLError, parse, validate } from 'graphql'; 3 | import path from 'path'; 4 | import { paginationDirectiveValidationRule } from '../paginationDirective'; 5 | 6 | const filePath = path.join(__dirname, '../../../../graphql-codegen-preset/src/utils/testing/example.graphql'); 7 | const schemaString = readFileSync(filePath, { encoding: 'utf-8' }); 8 | const schema = buildSchema(schemaString); 9 | 10 | const validateDocuments = (source: string) => { 11 | return validate(schema, parse(source), [paginationDirectiveValidationRule]); 12 | }; 13 | 14 | describe('paginationDirectiveValidationRule', () => { 15 | it('allows use of @pagination directive', () => { 16 | const errors = validateDocuments(/* GraphQL */ ` 17 | query TestQuery($cursor: String) { 18 | viewer { 19 | items(first: 1, after: $cursor) @pagination { 20 | edges { 21 | node { 22 | id 23 | } 24 | } 25 | } 26 | } 27 | } 28 | `); 29 | expect(errors).toHaveLength(0); 30 | }); 31 | 32 | it('disallows use of @pagination directive without after or before', () => { 33 | const errors = validateDocuments(/* GraphQL */ ` 34 | query TestQuery($cursor: String) { 35 | viewer { 36 | items(first: 1) @pagination { 37 | edges { 38 | node { 39 | id 40 | } 41 | } 42 | } 43 | } 44 | } 45 | `); 46 | expect(errors).toStrictEqual([new GraphQLError('@pagination directive is required `after` or `before` argument.')]); 47 | }); 48 | 49 | it('disallows use of @pagination directive with non-connection field', () => { 50 | const errors = validateDocuments(/* GraphQL */ ` 51 | query TestQuery($cursor: String) { 52 | viewer { 53 | items(first: 1, after: $cursor) { 54 | edges { 55 | node { 56 | id @pagination 57 | } 58 | } 59 | } 60 | } 61 | } 62 | `); 63 | expect(errors).toStrictEqual([ 64 | new GraphQLError('@pagination can only be used with types whose name ends with "Connection".'), 65 | ]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/config/src/validationRules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './paginationDirective'; 2 | export * from './validationRule'; 3 | -------------------------------------------------------------------------------- /packages/config/src/validationRules/paginationDirective.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | import { DirectiveNode, FieldNode, GraphQLOutputType, ValidationRule } from 'graphql'; 3 | import { GraphQLError, isNonNullType, isObjectType } from '../dependencies/graphql'; 4 | 5 | const PAGINATION_DIRECTIVE_NAME = 'pagination'; 6 | 7 | function hasAfterArgument(fieldNode: FieldNode): boolean { 8 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === 'after')); 9 | } 10 | 11 | function hasBeforeArgument(fieldNode: FieldNode): boolean { 12 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === 'before')); 13 | } 14 | 15 | const getPaginationDirective = (fieldNode: FieldNode): DirectiveNode | undefined => { 16 | if (!fieldNode.directives) return undefined; 17 | return fieldNode.directives.find((directive) => directive.name.value === PAGINATION_DIRECTIVE_NAME); 18 | }; 19 | 20 | function isPaginationType(type: GraphQLOutputType): boolean { 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 22 | const connectionType = isNonNullType(type) ? type.ofType : type; 23 | 24 | if (!isObjectType(connectionType)) { 25 | return false; 26 | } 27 | 28 | return connectionType.name.endsWith('Connection'); 29 | } 30 | 31 | export const paginationDirectiveValidationRule: ValidationRule = (context) => { 32 | return { 33 | Field: { 34 | enter(fieldNode) { 35 | const paginationDirective = getPaginationDirective(fieldNode); 36 | if (!paginationDirective) return; 37 | 38 | const type = context.getType(); 39 | if (!type || !isPaginationType(type)) { 40 | context.reportError( 41 | new GraphQLError( 42 | `@${PAGINATION_DIRECTIVE_NAME} can only be used with types whose name ends with "Connection".`, 43 | fieldNode, 44 | ), 45 | ); 46 | return; 47 | } 48 | 49 | if (!hasAfterArgument(fieldNode) && !hasBeforeArgument(fieldNode)) { 50 | context.reportError( 51 | new GraphQLError( 52 | `@${PAGINATION_DIRECTIVE_NAME} directive is required \`after\` or \`before\` argument.`, 53 | fieldNode, 54 | ), 55 | ); 56 | } 57 | }, 58 | }, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /packages/config/src/validationRules/validationRule.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RelayArgumentsOfCorrectType, 3 | RelayDefaultValueOfCorrectType, 4 | RelayKnownArgumentNames, 5 | RelayNoUnusedArguments, 6 | } from '@relay-graphql-js/validation-rules'; 7 | import { ValidationRule } from 'graphql'; 8 | import { paginationDirectiveValidationRule } from './paginationDirective'; 9 | 10 | export const validationRules: ValidationRule[] = [ 11 | RelayArgumentsOfCorrectType, 12 | RelayDefaultValueOfCorrectType, 13 | RelayNoUnusedArguments, 14 | RelayKnownArgumentNames, 15 | paginationDirectiveValidationRule, 16 | ] as unknown[] as ValidationRule[]; 17 | -------------------------------------------------------------------------------- /packages/config/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "emitDeclarationOnly": true 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__/", "**/*.test.ts", "**/testing/", "node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @kazekyo/nau 2 | 3 | Nau is a tool that makes the use of [Apollo Client](https://github.com/apollographql/apollo-client) more productive for users using [Relay GraphQL Server Specification](https://relay.dev/docs/guides/graphql-server-specification) compliant backends. 4 | 5 | See the [documentation](https://www.naugraphql.com/docs/introduction) for more information. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | yarn add @kazekyo/nau 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kazekyo/nau", 3 | "version": "0.4.4", 4 | "description": "Nau is a tool that makes the use of Apollo Client more productive for users using Relay GraphQL Server Specification compliant backends.", 5 | "author": "kazekyo", 6 | "bugs": { 7 | "url": "https://github.com/kazekyo/nau/issues" 8 | }, 9 | "dependencies": { 10 | "js-base64": "^3.6.0" 11 | }, 12 | "devDependencies": { 13 | "@types/react": "^18.0.8", 14 | "react": "^18.1.0", 15 | "react-dom": "^18.1.0" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "homepage": "https://www.naugraphql.com", 21 | "keywords": [ 22 | "apollo", 23 | "client", 24 | "relay", 25 | "graphql", 26 | "pagination", 27 | "cache", 28 | "directive" 29 | ], 30 | "license": "MIT", 31 | "main": "dist/index.js", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/kazekyo/nau.git" 35 | }, 36 | "scripts": { 37 | "test": "jest", 38 | "lint": "eslint src", 39 | "build": "yarn run build:types && yarn run build:js", 40 | "build:types": "tsc --build tsconfig.build.json", 41 | "build:js": "babel src --out-dir dist --root-mode upward --extensions \".ts,.tsx\" --source-maps inline --ignore \"**/__tests__/\",\"**/*.test.ts\",\"example\",\"**/testing/\"" 42 | }, 43 | "types": "dist/index.d.ts", 44 | "peerDependencies": { 45 | "@apollo/client": "^3.6.5", 46 | "graphql": "^15.6.1" 47 | }, 48 | "gitHead": "9337754515288d8fbd606f67b7ed88b14c6ef678", 49 | "publishConfig": { 50 | "access": "public", 51 | "registry": "https://registry.npmjs.org/" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/core/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePagination'; 2 | -------------------------------------------------------------------------------- /packages/core/src/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode, OperationVariables, TypedDocumentNode, useLazyQuery } from '@apollo/client'; 2 | import { getNodesFromConnection } from '../utils'; 3 | 4 | type LoadFunction = (count: number) => void; 5 | type PageInfo = { 6 | hasNextPage?: boolean; 7 | hasPreviousPage?: boolean; 8 | endCursor?: string | null; 9 | startCursor?: string | null; 10 | }; 11 | type Edge = { 12 | node?: TNode | null; 13 | }; 14 | 15 | export type UsePaginationOptions = { 16 | id: string; 17 | connection: { 18 | edges?: (Edge | null | undefined)[] | null; 19 | pageInfo?: PageInfo; 20 | }; 21 | variables?: TVariables; 22 | }; 23 | 24 | export const usePagination = ( 25 | document: DocumentNode | TypedDocumentNode, 26 | // If 'options' is undefined, it will always return empty nodes. It exists to prevent the use of conditions outside of this hook. 27 | // https://en.reactjs.org/docs/hooks-rules.html#explanation 28 | options: UsePaginationOptions | undefined, 29 | ): { 30 | nodes: TNode[]; 31 | loadNext: LoadFunction; 32 | loadPrevious: LoadFunction; 33 | hasNext: boolean; 34 | hasPrevious: boolean; 35 | isLoading: boolean; 36 | refetch?: ReturnType['1']['refetch']; 37 | } => { 38 | if (!options) 39 | return { 40 | nodes: [], 41 | loadNext: () => ({}), 42 | loadPrevious: () => ({}), 43 | hasNext: false, 44 | hasPrevious: false, 45 | isLoading: false, 46 | }; 47 | 48 | const { id, connection } = options; 49 | 50 | const [call, { called, loading, fetchMore, refetch }] = useLazyQuery(document); 51 | 52 | const load = (count: number, cursor: string | undefined | null) => { 53 | if (!cursor) return; 54 | const allVariables = { id, count, cursor, ...(options.variables || {}) } as unknown; 55 | if (called && fetchMore) { 56 | void fetchMore({ variables: allVariables as TVariables }); 57 | } else { 58 | void call({ variables: allVariables as TVariables, fetchPolicy: 'network-only', nextFetchPolicy: 'cache-first' }); 59 | } 60 | }; 61 | 62 | const loadNext: LoadFunction = (count) => load(count, connection?.pageInfo?.endCursor); 63 | const loadPrevious: LoadFunction = (count) => load(count, connection.pageInfo?.startCursor); 64 | 65 | const nodes = getNodesFromConnection({ connection }); 66 | 67 | return { 68 | nodes, 69 | loadNext, 70 | loadPrevious, 71 | hasNext: connection.pageInfo?.hasNextPage || false, 72 | hasPrevious: connection.pageInfo?.hasPreviousPage || false, 73 | isLoading: loading, 74 | refetch, 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hooks'; 2 | export * from './links'; 3 | export * from './policies'; 4 | export * from './utils'; 5 | -------------------------------------------------------------------------------- /packages/core/src/links/__tests__/cacheUpdaterLink.test.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink, execute, gql, Observable } from '@apollo/client'; 2 | import { print } from 'graphql/language'; 3 | import { createCacheUpdaterLink } from '../cacheUpdaterLink'; 4 | 5 | describe('cacheUpdaterLink', () => { 6 | const subjectLink = createCacheUpdaterLink(); 7 | it('removes directives and related variables from the mutation operation', (done) => { 8 | const document = gql` 9 | mutation Mutation($connections: [String!]!) { 10 | foo @prependNode(connections: $connections) { 11 | id 12 | } 13 | bar @appendNode(connections: $connections) { 14 | id 15 | } 16 | id @deleteRecord(typename: "Foo") 17 | } 18 | `; 19 | const expectedDocument = gql` 20 | mutation Mutation { 21 | foo { 22 | id 23 | } 24 | bar { 25 | id 26 | } 27 | id 28 | } 29 | `; 30 | const variables = { connections: ['dummyId'], other: 'ok' }; 31 | const expectedVariables = { other: 'ok' }; 32 | 33 | const successMockData = { data: { success: 'ok' } }; 34 | const mockLink = new ApolloLink((operation) => { 35 | expect(print(operation.query)).toBe(print(expectedDocument)); 36 | expect(operation.variables).toMatchObject(expectedVariables); 37 | return Observable.of(successMockData); 38 | }); 39 | const link = subjectLink.concat(mockLink); 40 | execute(link, { query: document, variables }).subscribe((result) => { 41 | expect(result).toBe(successMockData); 42 | done(); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/core/src/links/cacheUpdaterLink.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink, Operation } from '@apollo/client'; 2 | import { visit } from 'graphql/language'; 3 | import { uniq } from 'lodash'; 4 | import { CACHE_UPDATER_DIRECTIVE_NAMES, DELETE_VARIABLES_DIRECTIVE_NAMES } from '../utils'; 5 | import { isQueryOperation } from '../utils/graphqlAST'; 6 | import { createApolloLink } from './utils'; 7 | 8 | const transform = (operation: Operation): Operation => { 9 | const input = operation.query; 10 | if (isQueryOperation(input)) return operation; 11 | 12 | let argumentNames: string[] = []; 13 | visit(input, { 14 | Directive: { 15 | enter(node) { 16 | if ( 17 | DELETE_VARIABLES_DIRECTIVE_NAMES.find((name) => name === node.name.value) && 18 | node.arguments && 19 | node.arguments.length > 0 20 | ) { 21 | argumentNames = [...argumentNames, ...node.arguments.map((m) => m.name.value)]; 22 | } 23 | }, 24 | }, 25 | }); 26 | 27 | argumentNames = uniq(argumentNames); 28 | 29 | operation.query = visit(input, { 30 | VariableDefinition: { 31 | enter(node) { 32 | if (argumentNames.includes(node.variable.name.value)) { 33 | return null; 34 | } 35 | }, 36 | }, 37 | Directive: { 38 | enter(node) { 39 | if (CACHE_UPDATER_DIRECTIVE_NAMES.find((name) => name === node.name.value)) { 40 | return null; 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | operation.variables = Object.fromEntries( 47 | Object.entries(operation.variables).filter(([key]) => !argumentNames.includes(key)), 48 | ); 49 | 50 | return operation; 51 | }; 52 | 53 | export const createCacheUpdaterLink = (): ApolloLink => { 54 | return createApolloLink((operation) => transform(operation)); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/core/src/links/index.ts: -------------------------------------------------------------------------------- 1 | export { createCacheUpdaterLink } from './cacheUpdaterLink'; 2 | export * from './utils'; 3 | -------------------------------------------------------------------------------- /packages/core/src/links/utils.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink, FetchResult, Operation } from '@apollo/client'; 2 | import { Observable } from '@apollo/client/utilities'; 3 | import { isSubscriptionOperation } from '../utils'; 4 | 5 | export const createApolloLink = (transform: (operation: Operation) => Operation): ApolloLink => { 6 | return new ApolloLink((operation, forward) => { 7 | if (!forward) return null; 8 | 9 | const newOperation = transform(operation); 10 | if (isSubscriptionOperation(newOperation.query)) { 11 | return new Observable((observer) => 12 | forward(newOperation).subscribe((response) => observer.next(response)), 13 | ); 14 | } 15 | 16 | return forward(newOperation).map((response) => response); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/deleteRecord.mock.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { relayStylePagination } from '@apollo/client/utilities'; 3 | import { backendNodeIdGenerator } from '../../../utils/testing/backendNodeIdGenerator'; 4 | import { withCacheUpdaterInternal } from '../withCacheUpdater'; 5 | 6 | type ItemsConnectionType = { 7 | _connectionId: string; 8 | edges: { node: { id: string; __typename: 'Item' }; cursor: string }[]; 9 | pageInfo: { hasNextPage?: boolean; hasPreviousPage?: boolean; endCursor?: string; startCursor?: string }; 10 | }; 11 | 12 | export type QueryDataType = { 13 | items1: ItemsConnectionType; 14 | viewer: { 15 | id: string; 16 | __typename: 'User'; 17 | items2: ItemsConnectionType; 18 | }; 19 | }; 20 | 21 | const paginationMetaList = [ 22 | { 23 | node: { 24 | typename: 'Item', 25 | }, 26 | parents: [ 27 | { 28 | typename: 'Query', 29 | connection: { 30 | fieldName: 'items', 31 | }, 32 | edge: { 33 | typename: 'ItemEdge', 34 | }, 35 | }, 36 | { 37 | typename: 'User', 38 | connection: { 39 | fieldName: 'items', 40 | }, 41 | edge: { 42 | typename: 'ItemEdge', 43 | }, 44 | }, 45 | ], 46 | }, 47 | ]; 48 | 49 | const deleteRecordMetaList = [ 50 | { parent: { typename: 'RemovedItem' }, fields: [{ fieldName: 'id', typename: 'Item' }] }, 51 | { parent: { typename: 'ItemRemovedPayload' }, fields: [{ fieldName: 'id', typename: 'Item' }] }, 52 | ]; 53 | export const testTypePolicies = withCacheUpdaterInternal({ 54 | paginationMetaList, 55 | deleteRecordMetaList, 56 | typePolicies: { 57 | Query: { 58 | fields: { 59 | items: relayStylePagination(), 60 | }, 61 | }, 62 | User: { 63 | fields: { 64 | items: relayStylePagination(), 65 | }, 66 | }, 67 | }, 68 | }); 69 | 70 | export const userId = backendNodeIdGenerator({ typename: 'User', localId: '1' }); 71 | export const item1Id = backendNodeIdGenerator({ typename: 'Item', localId: '1' }); 72 | export const item2Id = backendNodeIdGenerator({ typename: 'Item', localId: '2' }); 73 | 74 | export const queryDocument = gql` 75 | query TestQuery($cursor1: String, $cursor2: String) { 76 | items1: items(first: 1, after: $cursor1) { 77 | _connectionId @client 78 | edges { 79 | node { 80 | id 81 | __typename 82 | } 83 | cursor 84 | } 85 | pageInfo { 86 | hasNextPage 87 | endCursor 88 | } 89 | } 90 | viewer { 91 | id 92 | __typename 93 | items2: items(first: 2, after: $cursor2) { 94 | _connectionId @client 95 | edges { 96 | node { 97 | id 98 | __typename 99 | } 100 | cursor 101 | } 102 | pageInfo { 103 | hasNextPage 104 | endCursor 105 | } 106 | } 107 | } 108 | } 109 | `; 110 | 111 | export const queryMockData = { 112 | items1: { 113 | edges: [{ node: { id: item1Id, __typename: 'Item' }, cursor: 'cursor-1' }], 114 | pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, 115 | }, 116 | viewer: { 117 | id: userId, 118 | __typename: 'User', 119 | items2: { 120 | edges: [ 121 | { node: { id: item1Id, __typename: 'Item' }, cursor: 'cursor-1' }, 122 | { node: { id: item2Id, __typename: 'Item' }, cursor: 'cursor-2' }, 123 | ], 124 | pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, 125 | }, 126 | }, 127 | }; 128 | 129 | export const mutationDocument = gql` 130 | mutation DeleteItemMutation($input: RemoveItemInput!) { 131 | removeItem(input: $input) { 132 | removedItem { 133 | id @deleteRecord(typename: "Item") 134 | __typename 135 | } 136 | } 137 | } 138 | `; 139 | export const mutationVariables = { 140 | input: { 141 | itemId: item1Id, 142 | userId: userId, 143 | }, 144 | }; 145 | export const mutationMockData = { removeItem: { removedItem: { id: item1Id, __typename: 'RemovedItem' } } }; 146 | 147 | export const subscriptionDocument = gql` 148 | subscription ItemDeletedSubscription($connections: [String!]!) { 149 | itemRemoved { 150 | id @deleteRecord(typename: "Item") 151 | __typename 152 | } 153 | } 154 | `; 155 | export const subscriptionMockData = { itemRemoved: { id: item1Id, __typename: 'ItemRemovedPayload' } }; 156 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/deleteRecord.test.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache, useMutation, useQuery, useSubscription } from '@apollo/client'; 2 | import { MockSubscriptionLink } from '@apollo/client/testing'; 3 | import '@testing-library/jest-dom'; 4 | import { act, waitFor } from '@testing-library/react'; 5 | import { renderHook } from '@testing-library/react'; 6 | import { clientMockedWrapperComponent, mockedWrapperComponent } from '../../../utils/testing/mockedWrapperComponent'; 7 | import { 8 | item2Id, 9 | mutationDocument, 10 | mutationMockData, 11 | mutationVariables, 12 | QueryDataType, 13 | queryDocument, 14 | queryMockData, 15 | subscriptionDocument, 16 | subscriptionMockData, 17 | testTypePolicies, 18 | } from './deleteRecord.mock'; 19 | 20 | describe('@deleteRecord', () => { 21 | let cache: InMemoryCache; 22 | beforeEach(() => { 23 | cache = new InMemoryCache({ 24 | typePolicies: testTypePolicies, 25 | }); 26 | }); 27 | 28 | it('deletes the mutation result from the cache', async () => { 29 | const mocks = [ 30 | { request: { query: queryDocument }, result: { data: queryMockData } }, 31 | { request: { query: mutationDocument, variables: mutationVariables }, result: { data: mutationMockData } }, 32 | ]; 33 | 34 | const wrapper = mockedWrapperComponent({ mocks, cache }); 35 | 36 | const useQueryHookResult = renderHook(() => useQuery(queryDocument), { wrapper }); 37 | await waitFor(() => { 38 | expect(useQueryHookResult.result.current.data?.items1.edges.length).toBe(1); 39 | }); 40 | expect(useQueryHookResult.result.current.data?.viewer.items2.edges.length).toBe(2); 41 | expect(useQueryHookResult.result.current.data).toMatchObject(queryMockData); 42 | 43 | const useMutationHookResult = renderHook(() => useMutation(mutationDocument), { wrapper }); 44 | act(() => { 45 | const [deleteFunc] = useMutationHookResult.result.current; 46 | void deleteFunc({ variables: mutationVariables }); 47 | }); 48 | 49 | await waitFor(() => { 50 | expect(useQueryHookResult.result.current.data?.items1.edges.length).toBe(0); 51 | }); 52 | expect(useQueryHookResult.result.current.data?.items1.edges.length).toBe(0); 53 | expect(useQueryHookResult.result.current.data?.viewer.items2.edges.length).toBe(1); 54 | expect(useQueryHookResult.result.current.data).toMatchObject({ 55 | items1: { 56 | edges: [], 57 | }, 58 | viewer: { 59 | items2: { 60 | edges: [{ node: { id: item2Id, __typename: 'Item' }, cursor: 'cursor-2' }], 61 | }, 62 | }, 63 | }); 64 | }); 65 | 66 | it('deletes the subscription result from the cache', async () => { 67 | const mocks = [{ request: { query: queryDocument }, result: { data: queryMockData } }]; 68 | const wrapper = mockedWrapperComponent({ mocks, cache }); 69 | const useQueryHookResult = renderHook(() => useQuery(queryDocument), { wrapper }); 70 | await waitFor(() => { 71 | expect(useQueryHookResult.result.current.data?.items1.edges.length).toBe(1); 72 | }); 73 | expect(useQueryHookResult.result.current.data?.items1.edges.length).toBe(1); 74 | expect(useQueryHookResult.result.current.data?.viewer.items2.edges.length).toBe(2); 75 | expect(useQueryHookResult.result.current.data).toMatchObject(queryMockData); 76 | 77 | const link = new MockSubscriptionLink(); 78 | const client = new ApolloClient({ link, cache }); 79 | const subscriptionWrapper = clientMockedWrapperComponent({ client }); 80 | renderHook(() => useSubscription(subscriptionDocument), { 81 | wrapper: subscriptionWrapper, 82 | }); 83 | act(() => { 84 | link.simulateResult({ result: { data: subscriptionMockData } }, true); 85 | }); 86 | await waitFor(() => { 87 | expect(useQueryHookResult.result.current.data?.items1.edges.length).toBe(0); 88 | }); 89 | expect(useQueryHookResult.result.current.data?.viewer.items2.edges.length).toBe(1); 90 | expect(useQueryHookResult.result.current.data).toMatchObject({ 91 | items1: { 92 | edges: [], 93 | }, 94 | viewer: { 95 | items2: { 96 | edges: [{ node: { id: item2Id, __typename: 'Item' }, cursor: 'cursor-2' }], 97 | }, 98 | }, 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/pagination/appendNode.mock.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { generateConnectionId } from '../../pagination'; 3 | import { item11Id, item12Id, item21Id, userId } from './mock'; 4 | 5 | export const mutationDocument = gql` 6 | mutation AddItemMutation($itemName: String!, $userId: ID!, $connections: [String!]!) { 7 | addItem(input: { itemName: $itemName, userId: $userId }) { 8 | item @appendNode(connections: $connections) { 9 | id 10 | __typename 11 | } 12 | } 13 | } 14 | `; 15 | 16 | export const mutationMockData = { addItem: { item: { id: item11Id, __typename: 'Item' } } }; 17 | 18 | export const mutationVariables = { 19 | itemName: 'item11', 20 | userId: userId, 21 | connections: [ 22 | generateConnectionId({ 23 | parent: { id: userId, typename: 'User' }, 24 | connection: { fieldName: 'items', args: {} }, 25 | edge: { typename: 'ItemEdge' }, 26 | }), 27 | ], 28 | }; 29 | 30 | export const differentArgsMutationMockData1 = { 31 | addItem: { item: { id: item11Id, __typename: 'Item', name: 'Item11' } }, 32 | }; 33 | 34 | export const differentArgsMutationVariables1 = { 35 | itemName: 'item11', 36 | userId: userId, 37 | connections: [ 38 | generateConnectionId({ 39 | parent: { id: userId, typename: 'User' }, 40 | connection: { fieldName: 'items', args: { search: '1' } }, 41 | edge: { typename: 'ItemEdge' }, 42 | }), 43 | ], 44 | }; 45 | 46 | export const differentArgsMutationMockData2 = { 47 | addItem: { item: { id: item12Id, __typename: 'Item', name: 'Item12' } }, 48 | }; 49 | 50 | export const differentArgsMutationVariables2 = { 51 | itemName: 'item12', 52 | userId: userId, 53 | connections: [ 54 | generateConnectionId({ 55 | parent: { id: userId, typename: 'User' }, 56 | connection: { fieldName: 'items', args: { search: '2' } }, 57 | edge: { typename: 'ItemEdge' }, 58 | }), 59 | ], 60 | }; 61 | 62 | export const appendItemToRootMutationVariables = { 63 | itemName: 'item11', 64 | userId: userId, 65 | connections: [ 66 | generateConnectionId({ 67 | parent: { id: 'ROOT_QUERY', typename: 'Query' }, 68 | connection: { fieldName: 'items', args: {} }, 69 | edge: { typename: 'ItemEdge' }, 70 | }), 71 | ], 72 | }; 73 | 74 | export const subscriptionDocument = gql` 75 | subscription ItemAddedSubscription($connections: [String!]!) { 76 | itemAdded { 77 | item @appendNode(connections: $connections) { 78 | id 79 | __typename 80 | } 81 | } 82 | } 83 | `; 84 | 85 | export const subscriptionMockData = { itemAdded: { item: { id: item21Id, __typename: 'Item' } } }; 86 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/pagination/connectionId.mock.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { userId } from './mock'; 3 | 4 | export type ConnectionIdOnlyQueryDataType = { 5 | items: { _connectionId: string }; 6 | viewer: { 7 | id: string; 8 | __typename: 'User'; 9 | items: { _connectionId: string }; 10 | }; 11 | }; 12 | 13 | export const connectionIdOnlyQueryDocument = gql` 14 | query TestQuery($cursor: String) { 15 | items(first: 1, after: $cursor) { 16 | _connectionId @client 17 | } 18 | viewer { 19 | id 20 | __typename 21 | items(first: 1, after: $cursor) { 22 | _connectionId @client 23 | } 24 | } 25 | } 26 | `; 27 | 28 | export const connectionIdOnlyQueryMockData = { 29 | items: {}, 30 | viewer: { 31 | id: userId, 32 | __typename: 'User', 33 | items: {}, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/pagination/connectionId.test.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache, useQuery } from '@apollo/client'; 2 | import '@testing-library/jest-dom'; 3 | import { renderHook, waitFor } from '@testing-library/react'; 4 | import { mockedWrapperComponent } from '../../../../utils/testing/mockedWrapperComponent'; 5 | import { generateConnectionId } from '../../pagination'; 6 | import { 7 | ConnectionIdOnlyQueryDataType, 8 | connectionIdOnlyQueryDocument, 9 | connectionIdOnlyQueryMockData, 10 | } from './connectionId.mock'; 11 | import { QueryDataType, queryDocument, queryMockData, testTypePolicies, userId } from './mock'; 12 | 13 | describe('_connectionId', () => { 14 | let cache: InMemoryCache; 15 | beforeEach(() => { 16 | cache = new InMemoryCache({ 17 | typePolicies: testTypePolicies, 18 | }); 19 | }); 20 | it('gets _connectionId of the connection of Root Query', async () => { 21 | const mocks = [{ request: { query: queryDocument }, result: { data: queryMockData } }]; 22 | const wrapper = mockedWrapperComponent({ mocks, cache }); 23 | const useQueryHookResult = renderHook(() => useQuery(queryDocument), { wrapper }); 24 | const connectionId = generateConnectionId({ 25 | parent: { id: 'ROOT_QUERY', typename: 'Query' }, 26 | connection: { fieldName: 'items', args: {} }, 27 | edge: { typename: 'ItemEdge' }, 28 | }); 29 | await waitFor(() => { 30 | expect(useQueryHookResult.result.current.data?.items._connectionId).toBe(connectionId); 31 | }); 32 | }); 33 | 34 | it('gets _connectionId of the connection of a type', async () => { 35 | const mocks = [{ request: { query: queryDocument }, result: { data: queryMockData } }]; 36 | const wrapper = mockedWrapperComponent({ mocks, cache }); 37 | const useQueryHookResult = renderHook(() => useQuery(queryDocument), { wrapper }); 38 | const connectionId = generateConnectionId({ 39 | parent: { id: userId, typename: 'User' }, 40 | connection: { fieldName: 'items', args: { search: 'item' } }, 41 | edge: { typename: 'ItemEdge' }, 42 | }); 43 | await waitFor(() => { 44 | expect(useQueryHookResult.result.current.data?.viewer.myItems._connectionId).toBe(connectionId); 45 | }); 46 | }); 47 | 48 | it('gets _connectionId of the connection with no edges etc. ', async () => { 49 | const mocks = [ 50 | { request: { query: connectionIdOnlyQueryDocument }, result: { data: connectionIdOnlyQueryMockData } }, 51 | ]; 52 | const wrapper = mockedWrapperComponent({ mocks, cache }); 53 | const useQueryHookResult = renderHook( 54 | () => useQuery(connectionIdOnlyQueryDocument), 55 | { wrapper }, 56 | ); 57 | const connectionId1 = generateConnectionId({ 58 | parent: { id: 'ROOT_QUERY', typename: 'Query' }, 59 | connection: { fieldName: 'items', args: {} }, 60 | edge: { typename: 'ItemEdge' }, 61 | }); 62 | const connectionId2 = generateConnectionId({ 63 | parent: { id: userId, typename: 'User' }, 64 | connection: { fieldName: 'items', args: {} }, 65 | edge: { typename: 'ItemEdge' }, 66 | }); 67 | await waitFor(() => { 68 | expect(useQueryHookResult.result.current.data?.items._connectionId).toBe(connectionId1); 69 | }); 70 | expect(useQueryHookResult.result.current.data?.items._connectionId).toBe(connectionId1); 71 | expect(useQueryHookResult.result.current.data?.viewer.items._connectionId).toBe(connectionId2); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/pagination/mock.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { relayStylePagination } from '@apollo/client/utilities'; 3 | import { backendNodeIdGenerator } from '../../../../utils/testing/backendNodeIdGenerator'; 4 | import { withCacheUpdaterInternal } from '../../withCacheUpdater'; 5 | 6 | type ItemsConnectionType = { 7 | _connectionId: string; 8 | edges: { node: { id: string; __typename: 'Item' }; cursor: string }[]; 9 | pageInfo: { hasNextPage?: boolean; hasPreviousPage?: boolean; endCursor?: string; startCursor?: string }; 10 | }; 11 | 12 | export type QueryDataType = { 13 | items: ItemsConnectionType; 14 | viewer: { 15 | id: string; 16 | __typename: 'User'; 17 | myItems: ItemsConnectionType; 18 | }; 19 | }; 20 | 21 | export type DifferentArgsConnectionsQueryDataType = { 22 | viewer: { 23 | id: string; 24 | __typename: 'User'; 25 | items1: ItemsConnectionType; 26 | items2: ItemsConnectionType; 27 | }; 28 | }; 29 | 30 | const paginationMetaList = [ 31 | { 32 | node: { 33 | typename: 'Item', 34 | }, 35 | parents: [ 36 | { 37 | typename: 'Query', 38 | connection: { 39 | fieldName: 'items', 40 | }, 41 | edge: { 42 | typename: 'ItemEdge', 43 | }, 44 | }, 45 | { 46 | typename: 'User', 47 | connection: { 48 | fieldName: 'items', 49 | }, 50 | edge: { 51 | typename: 'ItemEdge', 52 | }, 53 | }, 54 | ], 55 | }, 56 | ]; 57 | 58 | export const testTypePolicies = withCacheUpdaterInternal({ 59 | paginationMetaList, 60 | deleteRecordMetaList: [], 61 | typePolicies: { 62 | Query: { 63 | fields: { 64 | items: relayStylePagination(), 65 | }, 66 | }, 67 | User: { 68 | fields: { 69 | items: relayStylePagination(['search']), 70 | }, 71 | }, 72 | }, 73 | }); 74 | 75 | export const userId = backendNodeIdGenerator({ typename: 'User', localId: '1' }); 76 | // Use query 77 | export const item1Id = backendNodeIdGenerator({ typename: 'Item', localId: '1' }); 78 | export const item2Id = backendNodeIdGenerator({ typename: 'Item', localId: '2' }); 79 | // Use mutation 80 | export const item11Id = backendNodeIdGenerator({ typename: 'Item', localId: '11' }); 81 | export const item12Id = backendNodeIdGenerator({ typename: 'Item', localId: '12' }); 82 | // Use subscription 83 | export const item21Id = backendNodeIdGenerator({ typename: 'Item', localId: '21' }); 84 | 85 | export const queryDocument = gql` 86 | query TestQuery($cursor: String) { 87 | items { 88 | _connectionId @client 89 | edges { 90 | node { 91 | id 92 | __typename 93 | } 94 | cursor 95 | } 96 | pageInfo { 97 | hasNextPage 98 | endCursor 99 | } 100 | } 101 | viewer { 102 | id 103 | __typename 104 | myItems: items(first: 1, after: $cursor, search: "item") { 105 | _connectionId @client 106 | edges { 107 | node { 108 | id 109 | __typename 110 | } 111 | cursor 112 | } 113 | pageInfo { 114 | hasNextPage 115 | endCursor 116 | } 117 | } 118 | } 119 | } 120 | `; 121 | 122 | export const queryMockData = { 123 | items: { 124 | edges: [{ node: { id: item1Id, __typename: 'Item' }, cursor: 'cursor-1' }], 125 | pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, 126 | }, 127 | viewer: { 128 | id: userId, 129 | __typename: 'User', 130 | myItems: { 131 | edges: [{ node: { id: item1Id, __typename: 'Item' }, cursor: 'cursor-1' }], 132 | pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, 133 | }, 134 | }, 135 | }; 136 | 137 | export const differentArgsConnectionsQueryDocument = gql` 138 | query TestQuery($cursor: String) { 139 | viewer { 140 | id 141 | __typename 142 | items1: items(first: 1, after: $cursor, search: "1") { 143 | _connectionId @client 144 | edges { 145 | node { 146 | id 147 | __typename 148 | } 149 | cursor 150 | } 151 | pageInfo { 152 | hasNextPage 153 | endCursor 154 | } 155 | } 156 | items2: items(first: 1, after: $cursor, search: "2") { 157 | _connectionId @client 158 | edges { 159 | node { 160 | id 161 | __typename 162 | } 163 | cursor 164 | } 165 | pageInfo { 166 | hasNextPage 167 | endCursor 168 | } 169 | } 170 | } 171 | } 172 | `; 173 | export const differentArgsConnectionsQueryMockData = { 174 | viewer: { 175 | id: userId, 176 | __typename: 'User', 177 | items1: { 178 | edges: [{ node: { id: item1Id, __typename: 'Item' }, cursor: 'cursor-1' }], 179 | pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, 180 | }, 181 | items2: { 182 | edges: [{ node: { id: item2Id, __typename: 'Item' }, cursor: 'cursor-2' }], 183 | pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, 184 | }, 185 | }, 186 | }; 187 | 188 | export const prependItemMutationDocument = gql` 189 | mutation AddItemMutation($itemName: String!, $userId: ID!, $connections: [String!]!) { 190 | addItem(input: { itemName: $itemName, userId: $userId }) { 191 | item @prependNode(connections: $connections) { 192 | id 193 | __typename 194 | } 195 | } 196 | } 197 | `; 198 | export const prependItemMutationMockData = { addItem: { item: { id: item11Id, __typename: 'Item' } } }; 199 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/__tests__/pagination/prependNode.mock.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | import { generateConnectionId } from '../../pagination'; 3 | import { item11Id, item12Id, item21Id, userId } from './mock'; 4 | 5 | export const mutationDocument = gql` 6 | mutation AddItemMutation($itemName: String!, $userId: ID!, $connections: [String!]!) { 7 | addItem(input: { itemName: $itemName, userId: $userId }) { 8 | item @prependNode(connections: $connections) { 9 | id 10 | __typename 11 | } 12 | } 13 | } 14 | `; 15 | 16 | export const mutationMockData = { addItem: { item: { id: item11Id, __typename: 'Item' } } }; 17 | 18 | export const mutationVariables = { 19 | itemName: 'item11', 20 | userId: userId, 21 | connections: [ 22 | generateConnectionId({ 23 | parent: { id: userId, typename: 'User' }, 24 | connection: { fieldName: 'items', args: {} }, 25 | edge: { typename: 'ItemEdge' }, 26 | }), 27 | ], 28 | }; 29 | 30 | export const differentArgsMutationMockData1 = { 31 | addItem: { item: { id: item11Id, __typename: 'Item', name: 'Item11' } }, 32 | }; 33 | 34 | export const differentArgsMutationVariables1 = { 35 | itemName: 'item11', 36 | userId: userId, 37 | connections: [ 38 | generateConnectionId({ 39 | parent: { id: userId, typename: 'User' }, 40 | connection: { fieldName: 'items', args: { search: '1' } }, 41 | edge: { typename: 'ItemEdge' }, 42 | }), 43 | ], 44 | }; 45 | 46 | export const differentArgsMutationMockData2 = { 47 | addItem: { item: { id: item12Id, __typename: 'Item', name: 'Item12' } }, 48 | }; 49 | 50 | export const differentArgsMutationVariables2 = { 51 | itemName: 'item12', 52 | userId: userId, 53 | connections: [ 54 | generateConnectionId({ 55 | parent: { id: userId, typename: 'User' }, 56 | connection: { fieldName: 'items', args: { search: '2' } }, 57 | edge: { typename: 'ItemEdge' }, 58 | }), 59 | ], 60 | }; 61 | 62 | export const prependItemToRootMutationVariables = { 63 | itemName: 'item11', 64 | userId: userId, 65 | connections: [ 66 | generateConnectionId({ 67 | parent: { id: 'ROOT_QUERY', typename: 'Query' }, 68 | connection: { fieldName: 'items', args: {} }, 69 | edge: { typename: 'ItemEdge' }, 70 | }), 71 | ], 72 | }; 73 | 74 | export const subscriptionDocument = gql` 75 | subscription ItemAddedSubscription($connections: [String!]!) { 76 | itemAdded { 77 | item @prependNode(connections: $connections) { 78 | id 79 | __typename 80 | } 81 | } 82 | } 83 | `; 84 | 85 | export const subscriptionMockData = { itemAdded: { item: { id: item21Id, __typename: 'Item' } } }; 86 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/deleteRecord.ts: -------------------------------------------------------------------------------- 1 | import { FieldFunctionOptions, Reference, TypePolicies, TypePolicy } from '@apollo/client'; 2 | import { FieldNode } from 'graphql'; 3 | import { findDirectiveName } from '../../utils/directiveName'; 4 | import { generateTypePolicyPairWithTypeMergeFunction, TypePolicyPair } from './util'; 5 | 6 | export type DeleteRecordMeta = { 7 | parent: { 8 | typename: string; 9 | }; 10 | fields: { fieldName: string; typename: string }[]; 11 | }; 12 | 13 | export const generateDeleteRecordTypePolicyPairs = ({ 14 | deleteRecordMetaList, 15 | typePolicies, 16 | }: { 17 | deleteRecordMetaList: DeleteRecordMeta[]; 18 | typePolicies: TypePolicies; 19 | }): [string, TypePolicy][] => { 20 | return deleteRecordMetaList.map((metadata): TypePolicyPair => { 21 | return generateTypePolicyPairWithTypeMergeFunction({ 22 | innerFunction: ({ mergedObject, options }) => deleteRecord({ object: mergedObject, options, metadata }), 23 | typename: metadata.parent.typename, 24 | typePolicies, 25 | }); 26 | }); 27 | }; 28 | 29 | const deleteRecord = ({ 30 | object, 31 | options, 32 | metadata, 33 | }: { 34 | object: Reference; 35 | options: FieldFunctionOptions; 36 | metadata: DeleteRecordMeta; 37 | }): void => { 38 | const { cache, field, readField } = options; 39 | 40 | const fieldsWithDeleteRecord = 41 | field?.selectionSet?.selections 42 | .filter( 43 | (selection): selection is FieldNode => 44 | !!findDirectiveName({ node: selection, directiveNames: ['deleteRecord'] }) && selection.kind === 'Field', 45 | ) 46 | .map((selection) => selection.name.value) || []; 47 | 48 | if (fieldsWithDeleteRecord.length === 0) return; 49 | 50 | metadata.fields.forEach((fieldMeta) => { 51 | if (!fieldsWithDeleteRecord.includes(fieldMeta.fieldName)) return; 52 | const id = readField({ fieldName: fieldMeta.fieldName, from: object }); 53 | if (typeof id != 'string') return; 54 | const cacheId = cache.identify({ id, __typename: fieldMeta.typename }); 55 | cache.evict({ id: cacheId }); 56 | }); 57 | 58 | cache.gc(); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/index.ts: -------------------------------------------------------------------------------- 1 | export { DeleteRecordMeta } from './deleteRecord'; 2 | export { PaginationMeta } from './pagination'; 3 | export * from './withCacheUpdater'; 4 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/util.ts: -------------------------------------------------------------------------------- 1 | import { FieldFunctionOptions, FieldMergeFunction, Reference, TypePolicies, TypePolicy } from '@apollo/client'; 2 | 3 | export type TypePolicyPair = [string, TypePolicy]; 4 | 5 | export const findTypePolicy = ({ 6 | key, 7 | typePolicies, 8 | }: { 9 | key: string; 10 | typePolicies: TypePolicies; 11 | }): TypePolicy | undefined => { 12 | const keys = Object.keys(typePolicies); 13 | if (keys.includes(key)) { 14 | return typePolicies[key]; 15 | } 16 | return undefined; 17 | }; 18 | 19 | export const generateTypePolicyPairWithTypeMergeFunction = ({ 20 | innerFunction, 21 | typename, 22 | typePolicies, 23 | }: { 24 | innerFunction: (params: { mergedObject: Reference; incoming: Reference; options: FieldFunctionOptions }) => void; 25 | typename: string; 26 | typePolicies: TypePolicies; 27 | }): TypePolicyPair => { 28 | const originalTypePolicy = findTypePolicy({ key: typename, typePolicies }); 29 | 30 | if (!originalTypePolicy) { 31 | return [ 32 | typename, 33 | { 34 | merge: (existing, incoming, options) => { 35 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 36 | const object = options.mergeObjects(existing, incoming); 37 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 38 | innerFunction({ mergedObject: object, incoming, options }); 39 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 40 | return object; 41 | }, 42 | }, 43 | ]; 44 | } 45 | 46 | const typePolicy: TypePolicy = { 47 | ...originalTypePolicy, 48 | merge: (existing: Reference, incoming: Reference, options) => { 49 | const object = options.mergeObjects(existing, incoming); 50 | if (object) innerFunction({ mergedObject: object, incoming, options }); 51 | return callMerge({ merge: originalTypePolicy.merge, mergedObject: object, incoming, options }); 52 | }, 53 | }; 54 | return [typename, typePolicy]; 55 | }; 56 | 57 | const callMerge = ({ 58 | merge, 59 | mergedObject, 60 | incoming, 61 | options, 62 | }: { 63 | merge?: FieldMergeFunction | boolean; 64 | mergedObject: Reference; 65 | incoming: Reference; 66 | options: FieldFunctionOptions; 67 | }): Reference => { 68 | if (merge === false) { 69 | return incoming; 70 | } else if (merge === true || !merge) { 71 | return mergedObject; 72 | } else { 73 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 74 | return merge(mergedObject, incoming, options); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /packages/core/src/policies/cacheUpdater/withCacheUpdater.ts: -------------------------------------------------------------------------------- 1 | import { TypePolicies } from '@apollo/client'; 2 | import { uniqWith } from 'lodash'; 3 | import { DeleteRecordMeta, generateDeleteRecordTypePolicyPairs } from './deleteRecord'; 4 | import { 5 | generatePaginationNodeTypePolicyPairs, 6 | generatePaginationParentTypePolicyPairs, 7 | PaginationMeta, 8 | } from './pagination'; 9 | import { TypePolicyPair } from './util'; 10 | 11 | const mergeTypePolicyPairs = (pairsList: Array): TypePolicyPair[] => { 12 | const allTypename = uniqWith( 13 | pairsList.flat().map(([key, _]) => key), 14 | (a, b) => a === b, 15 | ); 16 | return allTypename.map((typename) => { 17 | const pairs = pairsList.flat().filter((pair) => pair[0] === typename); 18 | let newTypePolicy = {}; 19 | pairs.forEach((pair) => { 20 | newTypePolicy = { ...newTypePolicy, ...pair[1] }; 21 | }); 22 | return [typename, newTypePolicy]; 23 | }); 24 | }; 25 | 26 | const typePoliciesFromTypePolicyPairs = (typePolicyPairs: TypePolicyPair[]): TypePolicies => { 27 | return Object.fromEntries(typePolicyPairs); 28 | }; 29 | 30 | // NOTE: If you want to remove an edge from an edges of a connection when using @deleteRecord, 31 | // you must set the information of that connection to `paginationMetaList`. 32 | // This means that the connection requires the @pagination directive. 33 | export const withCacheUpdaterInternal = ({ 34 | paginationMetaList, 35 | deleteRecordMetaList, 36 | typePolicies, 37 | }: { 38 | paginationMetaList: PaginationMeta[]; 39 | deleteRecordMetaList: DeleteRecordMeta[]; 40 | typePolicies: TypePolicies; 41 | }): TypePolicies => { 42 | const paginationParentTypePolicyPairs = generatePaginationParentTypePolicyPairs({ paginationMetaList, typePolicies }); 43 | const paginationNodeTypePolicyPairs = generatePaginationNodeTypePolicyPairs({ paginationMetaList, typePolicies }); 44 | const paginationTypePolicyPairs = mergeTypePolicyPairs([ 45 | paginationParentTypePolicyPairs, 46 | paginationNodeTypePolicyPairs, 47 | ]); 48 | 49 | let newTypePolicies = { 50 | ...typePolicies, 51 | ...typePoliciesFromTypePolicyPairs(paginationTypePolicyPairs), 52 | }; 53 | 54 | const deleteRecordTypePolicyPairs = generateDeleteRecordTypePolicyPairs({ 55 | deleteRecordMetaList, 56 | typePolicies: newTypePolicies, 57 | }); 58 | 59 | newTypePolicies = { ...newTypePolicies, ...typePoliciesFromTypePolicyPairs(deleteRecordTypePolicyPairs) }; 60 | 61 | return newTypePolicies; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/core/src/policies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cacheUpdater'; 2 | -------------------------------------------------------------------------------- /packages/core/src/utils/directiveName.ts: -------------------------------------------------------------------------------- 1 | import { FieldNode, SelectionNode } from 'graphql/language'; 2 | 3 | export type DirectiveName = 4 | | 'appendNode' 5 | | 'prependNode' 6 | | 'deleteRecord' 7 | | 'argumentDefinitions' 8 | | 'arguments' 9 | | 'refetchable' 10 | | 'pagination'; 11 | 12 | export const ARGUMENT_DEFINITIONS_DIRECTIVE_NAME = 'argumentDefinitions'; 13 | export const ARGUMENTS_DIRECTIVE_NAME = 'arguments'; 14 | export const REFETCHABLE_DIRECTIVE_NAME = 'refetchable'; 15 | export const PAGINATION_DIRECTIVE_NAME = 'pagination'; 16 | export const DELETE_RECORD_DIRECTIVE_NAME = 'deleteRecord'; 17 | export const INSERT_NODE_DIRECTIVE_NAMES: DirectiveName[] = ['appendNode', 'prependNode']; 18 | export const DELETE_VARIABLES_DIRECTIVE_NAMES: DirectiveName[] = [...INSERT_NODE_DIRECTIVE_NAMES]; 19 | export const CACHE_UPDATER_DIRECTIVE_NAMES: DirectiveName[] = [ 20 | ...INSERT_NODE_DIRECTIVE_NAMES, 21 | DELETE_RECORD_DIRECTIVE_NAME, 22 | ]; 23 | 24 | export const findDirectiveName = ({ 25 | node, 26 | directiveNames, 27 | }: { 28 | node: FieldNode | SelectionNode | null; 29 | directiveNames: DirectiveName[]; 30 | }): DirectiveName | undefined => { 31 | const directiveName = node?.directives?.find((directive) => 32 | directiveNames.find((name) => name === directive.name.value), 33 | )?.name.value; 34 | if (directiveName) return directiveName as DirectiveName; 35 | return undefined; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/core/src/utils/getNodes.ts: -------------------------------------------------------------------------------- 1 | export const getNodesFromConnection = ({ 2 | connection, 3 | }: { 4 | connection: 5 | | { 6 | edges?: Array<{ node?: TNode | null } | undefined | null> | null; 7 | } 8 | | undefined 9 | | null; 10 | }): TNode[] => { 11 | return connection?.edges?.map((edge) => edge?.node).filter((item): item is NonNullable => !!item) || []; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/core/src/utils/graphqlAST.ts: -------------------------------------------------------------------------------- 1 | import { getMainDefinition } from '@apollo/client/utilities'; 2 | import { DocumentNode } from 'graphql/language'; 3 | 4 | export const isQueryOperation = (node: DocumentNode): boolean => { 5 | const mainDefinition = getMainDefinition(node); 6 | return mainDefinition.kind === 'OperationDefinition' && mainDefinition.operation === 'query'; 7 | }; 8 | 9 | export const isSubscriptionOperation = (query: DocumentNode): boolean => { 10 | const mainDefinition = getMainDefinition(query); 11 | return mainDefinition.kind === 'OperationDefinition' && mainDefinition.operation === 'subscription'; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/core/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './directiveName'; 2 | export * from './getNodes'; 3 | export * from './graphqlAST'; 4 | -------------------------------------------------------------------------------- /packages/core/src/utils/testing/backendNodeIdGenerator.ts: -------------------------------------------------------------------------------- 1 | import { encode } from 'js-base64'; 2 | export const backendNodeIdGenerator = ({ typename, localId }: { typename: string; localId: string }): string => 3 | encode(`${typename}|${localId}`); 4 | -------------------------------------------------------------------------------- /packages/core/src/utils/testing/mockedWrapperComponent.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloCache, ApolloClient, ApolloProvider, NormalizedCacheObject } from '@apollo/client'; 2 | import { MockedProvider, MockedResponse } from '@apollo/client/testing'; 3 | import '@testing-library/jest-dom'; 4 | import * as React from 'react'; 5 | 6 | export const mockedWrapperComponent = ({ 7 | mocks, 8 | cache, 9 | }: { 10 | mocks: MockedResponse[]; 11 | cache: ApolloCache>; 12 | }): React.FC<{ children: React.ReactChild }> => { 13 | return ({ children }: { children: React.ReactChild }) => ( 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | export const clientMockedWrapperComponent = ({ 21 | client, 22 | }: { 23 | client: ApolloClient; 24 | }): React.FC<{ children: React.ReactChild }> => { 25 | return ({ children }: { children: React.ReactChild }) => {children}; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/core/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "emitDeclarationOnly": true 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__/", "**/*.test.ts", "**/testing/", "node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/README.md: -------------------------------------------------------------------------------- 1 | # @kazekyo/nau-graphql-codegen-preset 2 | 3 | > A GraphQL Code Generator preset for [Nau](https://github.com/kazekyo/nau) 4 | 5 | See the [documentation](https://www.naugraphql.com/docs/introduction) for more information. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | yarn add --dev @kazekyo/nau-graphql-codegen-preset 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kazekyo/nau-graphql-codegen-preset", 3 | "version": "0.4.5", 4 | "description": "A GraphQL Code Generator preset for Nau", 5 | "author": "kazekyo", 6 | "bugs": { 7 | "url": "https://github.com/kazekyo/nau/issues" 8 | }, 9 | "dependencies": { 10 | "@graphql-codegen/plugin-helpers": "^4.0.0", 11 | "@graphql-codegen/visitor-plugin-common": "^3.0.0", 12 | "@graphql-tools/utils": "^9.1.3", 13 | "@kazekyo/nau": "^0.4.4", 14 | "@kazekyo/nau-config": "^0.4.4", 15 | "@relay-graphql-js/validation-rules": "^0.1.0", 16 | "js-base64": "^3.6.0", 17 | "lodash.clonedeep": "^4.5.0" 18 | }, 19 | "devDependencies": { 20 | "@apollo/client": "^3.6.2", 21 | "@graphql-codegen/cli": "^3.0.0", 22 | "@graphql-codegen/testing": "^1.17.7", 23 | "@types/lodash.clonedeep": "^4.5.6" 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "homepage": "https://www.naugraphql.com", 29 | "keywords": [ 30 | "apollo", 31 | "client", 32 | "relay", 33 | "graphql", 34 | "pagination", 35 | "cache", 36 | "directive" 37 | ], 38 | "license": "MIT", 39 | "main": "dist/index.js", 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/kazekyo/nau.git" 43 | }, 44 | "scripts": { 45 | "test": "jest", 46 | "lint": "eslint src", 47 | "build": "yarn run build:types && yarn run build:js", 48 | "build:types": "tsc --build tsconfig.build.json", 49 | "build:js": "babel src --out-dir dist --root-mode upward --extensions \".ts,.tsx\" --source-maps inline --ignore \"**/__tests__/\",\"**/*.test.ts\",\"example\",\"**/testing/\"" 50 | }, 51 | "types": "dist/index.d.ts", 52 | "peerDependencies": { 53 | "graphql": "^15.6.1" 54 | }, 55 | "gitHead": "6579f7231e41383ef50ade8820137e975b579047", 56 | "publishConfig": { 57 | "access": "public", 58 | "registry": "https://registry.npmjs.org/" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/__tests__/__snapshots__/preset.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`preset generateTypeScriptCode outputs TypeScript code when the flag is true 1`] = ` 4 | "import { TypePolicy } from '@apollo/client'; 5 | import { withCacheUpdaterInternal } from '@kazekyo/nau'; 6 | 7 | export const paginationMetaList = [{ node: { typename: 'Item' }, parents: [{ typename: 'User', connection: { fieldName: 'items' }, edge: { typename: 'ItemEdge' } }] }]; 8 | 9 | export const deleteRecordMetaList = [{ parent: { typename: 'RemovedItem' }, fields: [{ fieldName: 'id', typename: 'Item' }] }, { parent: { typename: 'ItemRemovedPayload' }, fields: [{ fieldName: 'id', typename: 'Item' }] }]; 10 | 11 | export type CacheUpdaterTypePolicies = { 12 | User: TypePolicy; 13 | [__typename: string]: TypePolicy; 14 | }; 15 | 16 | export const withCacheUpdater = (typePolicies: CacheUpdaterTypePolicies) => 17 | withCacheUpdaterInternal({ 18 | paginationMetaList, 19 | deleteRecordMetaList, 20 | typePolicies, 21 | });" 22 | `; 23 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/__tests__/fixtures/exampleFile.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | const myFragment = gql` 4 | fragment MyFragment_user on User 5 | @refetchable(queryName: "App_PaginationQuery") 6 | @argumentDefinitions(count: { type: "Int", defaultValue: 5 }, cursor: { type: "String" }) { 7 | name 8 | items(first: $count, after: $cursor) @pagination { 9 | edges { 10 | node { 11 | id 12 | name 13 | } 14 | } 15 | } 16 | } 17 | `; 18 | 19 | gql` 20 | query MyAppQuery { 21 | viewer { 22 | id 23 | ...MyFragment_user @arguments(count: 5) 24 | } 25 | } 26 | ${myFragment} 27 | `; 28 | 29 | gql` 30 | mutation DeleteItemMutation($input: RemoveItemInput!) { 31 | removeItem(input: $input) { 32 | removedItem { 33 | id @deleteRecord(typename: "Item") 34 | __typename 35 | } 36 | } 37 | } 38 | `; 39 | 40 | gql` 41 | subscription ItemDeletedSubscription($connections: [String!]!) { 42 | itemRemoved { 43 | id @deleteRecord(typename: "Item") 44 | __typename 45 | } 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/__tests__/preset.test.ts: -------------------------------------------------------------------------------- 1 | import { executeCodegen } from '@graphql-codegen/cli'; 2 | import { Source } from '@graphql-tools/utils'; 3 | import { parse } from 'graphql'; 4 | import path from 'path'; 5 | import { preset } from '../preset'; 6 | import { printDocuments, testSchemaDocumentNode } from '../utils/testing/utils'; 7 | 8 | describe('preset', () => { 9 | it('transforms documents', async () => { 10 | const documents: Source[] = [ 11 | { 12 | document: parse(/* GraphQL */ ` 13 | query TestQuery($cursor: String) { 14 | viewer { 15 | ...Fragment_user @arguments(count: 5) 16 | } 17 | } 18 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 19 | addItem(input: $input) { 20 | item @prependNode(connections: $connections) { 21 | name 22 | } 23 | } 24 | } 25 | subscription ItemAddedSubscription($connections: [String!]!) { 26 | itemAdded { 27 | item @prependNode(connections: $connections) { 28 | name 29 | } 30 | } 31 | } 32 | fragment Fragment_user on User @argumentDefinitions(count: { type: "Int", defaultValue: 1 }) { 33 | items(first: $count, after: $cursor) @pagination { 34 | edges { 35 | node { 36 | id 37 | } 38 | } 39 | } 40 | } 41 | `), 42 | }, 43 | ]; 44 | const expectedDocument = parse(/* GraphQL */ ` 45 | query TestQuery($cursor: String) { 46 | viewer { 47 | ...Fragment_user_bi8xLGNvdW50OjU 48 | } 49 | } 50 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 51 | addItem(input: $input) { 52 | item @prependNode(connections: $connections) { 53 | name 54 | id 55 | __typename 56 | } 57 | } 58 | } 59 | subscription ItemAddedSubscription($connections: [String!]!) { 60 | itemAdded { 61 | item @prependNode(connections: $connections) { 62 | name 63 | id 64 | __typename 65 | } 66 | } 67 | } 68 | fragment Fragment_user_bi8xLGNvdW50OjU on User { 69 | items(first: 5, after: $cursor) { 70 | edges { 71 | node { 72 | id 73 | __typename 74 | } 75 | cursor 76 | } 77 | pageInfo { 78 | hasNextPage 79 | endCursor 80 | hasPreviousPage 81 | startCursor 82 | } 83 | _connectionId @client 84 | } 85 | id 86 | __typename 87 | } 88 | `); 89 | 90 | const result = await preset.buildGeneratesSection({ 91 | baseOutputDir: 'src/generated/graphql.ts', 92 | config: {}, 93 | presetConfig: {}, 94 | schema: testSchemaDocumentNode, 95 | documents: documents, 96 | plugins: [], 97 | pluginMap: {}, 98 | }); 99 | 100 | expect(printDocuments(result[0].documents)).toBe(printDocuments([{ document: expectedDocument }])); 101 | }); 102 | 103 | describe('generateTypeScriptCode', () => { 104 | it('outputs nothing code to the content when the flag is not set to true', async () => { 105 | const input = { 106 | schema: path.join(__dirname, '../utils/testing/example.graphql'), 107 | documents: path.join(__dirname, './fixtures/exampleFile.ts'), 108 | generates: { 109 | ['./generated.ts']: { 110 | preset, 111 | plugins: [], 112 | }, 113 | }, 114 | }; 115 | 116 | const result = await executeCodegen(input); 117 | expect(result[0].content).toBe(''); 118 | }); 119 | 120 | it('outputs TypeScript code when the flag is true', async () => { 121 | const input = { 122 | schema: path.join(__dirname, '../utils/testing/example.graphql'), 123 | documents: path.join(__dirname, './fixtures/exampleFile.ts'), 124 | generates: { 125 | ['./generated.ts']: { 126 | preset, 127 | plugins: [], 128 | presetConfig: { 129 | generateTypeScriptCode: true, 130 | }, 131 | }, 132 | }, 133 | }; 134 | 135 | const result = await executeCodegen(input); 136 | expect(result[0].content).toMatchSnapshot(); 137 | }); 138 | }); 139 | 140 | describe('Multiple Sections', () => { 141 | it('works with no errors', async () => { 142 | const input = { 143 | schema: path.join(__dirname, '../utils/testing/example.graphql'), 144 | generates: { 145 | ['./generated.ts']: { 146 | preset, 147 | plugins: [], 148 | presetConfig: { 149 | generateTypeScriptCode: true, 150 | }, 151 | documents: path.join(__dirname, './fixtures/exampleFile.ts'), 152 | }, 153 | ['./generated2.ts']: { 154 | preset, 155 | plugins: [], 156 | presetConfig: { 157 | generateTypeScriptCode: true, 158 | }, 159 | documents: path.join(__dirname, './fixtures/exampleFile.ts'), 160 | }, 161 | ['./generated3.ts']: { 162 | preset, 163 | plugins: [], 164 | documents: path.join(__dirname, './fixtures/exampleFile.ts'), 165 | }, 166 | }, 167 | }; 168 | 169 | const result = await executeCodegen(input); 170 | expect(result.length).toBe(3); 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/index.ts: -------------------------------------------------------------------------------- 1 | import { preset } from './preset'; 2 | export default preset; 3 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/plugins/cache-updater-support/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { mergeOutputs } from '@graphql-codegen/plugin-helpers'; 2 | import { validateTs } from '@graphql-codegen/testing'; 3 | import { parse } from 'graphql'; 4 | import { plugin } from '..'; 5 | import { testGraphqlSchema } from '../../../utils/testing/utils'; 6 | 7 | describe('cache-updater-support', () => { 8 | const documentsForPagination = [ 9 | { 10 | document: parse(/* GraphQL */ ` 11 | query TestQuery($cursor1: String, $cursor2: String, $cursor3: String) { 12 | itemsOnRoot: items(first: 1, after: $cursor1, keyword: "test") @pagination { 13 | edges { 14 | node { 15 | id 16 | __typename 17 | } 18 | cursor 19 | } 20 | pageInfo { 21 | hasNextPage 22 | endCursor 23 | } 24 | } 25 | viewer { 26 | id 27 | ...Fragment_user 28 | myItems: items(first: 1, after: $cursor2, keyword: "test2") @pagination { 29 | edges { 30 | node { 31 | name 32 | id 33 | __typename 34 | } 35 | cursor 36 | } 37 | pageInfo { 38 | hasNextPage 39 | endCursor 40 | } 41 | } 42 | } 43 | } 44 | fragment Fragment_user on User { 45 | id 46 | itemsOnFragment: items(first: 1, after: $cursor2, keyword: "test3") @pagination { 47 | edges { 48 | node { 49 | name 50 | id 51 | __typename 52 | } 53 | cursor 54 | } 55 | pageInfo { 56 | hasNextPage 57 | endCursor 58 | } 59 | } 60 | } 61 | `), 62 | }, 63 | { 64 | document: parse(/* GraphQL */ ` 65 | mutation DeleteItemMutation($input: RemoveItemInput!) { 66 | removeItem(input: $input) { 67 | removedItem { 68 | id @deleteRecord(typename: "Item") 69 | __typename 70 | } 71 | } 72 | } 73 | `), 74 | }, 75 | { 76 | document: parse(/* GraphQL */ ` 77 | subscription ItemDeletedSubscription($connections: [String!]!) { 78 | itemRemoved { 79 | id @deleteRecord(typename: "Item") 80 | __typename 81 | } 82 | } 83 | `), 84 | }, 85 | ]; 86 | 87 | it('generates paginationMetaList code', async () => { 88 | const result = await plugin(testGraphqlSchema, documentsForPagination, { documentFiles: documentsForPagination }); 89 | const merged = mergeOutputs([result]); 90 | validateTs(merged, undefined, false, true); 91 | expect(result.content).toContain( 92 | `export const paginationMetaList = [{ node: { typename: 'Item' }, parents: [{ typename: 'Query', connection: { fieldName: 'items' }, edge: { typename: 'ItemEdge' } }, { typename: 'User', connection: { fieldName: 'items' }, edge: { typename: 'ItemEdge' } }] }];`, 93 | ); 94 | }); 95 | 96 | it('generates deleteRecordMetaList code', async () => { 97 | const result = await plugin(testGraphqlSchema, documentsForPagination, { documentFiles: documentsForPagination }); 98 | const merged = mergeOutputs([result]); 99 | validateTs(merged, undefined, false, true); 100 | expect(result.content).toContain( 101 | `const deleteRecordMetaList = [{ parent: { typename: 'RemovedItem' }, fields: [{ fieldName: 'id', typename: 'Item' }] }, { parent: { typename: 'ItemRemovedPayload' }, fields: [{ fieldName: 'id', typename: 'Item' }] }];`, 102 | ); 103 | }); 104 | 105 | it('generates withCacheUpdater code', async () => { 106 | const result = await plugin(testGraphqlSchema, documentsForPagination, { documentFiles: documentsForPagination }); 107 | const merged = mergeOutputs([result]); 108 | validateTs(merged, undefined, false, true); 109 | expect(result.content).toContain( 110 | ` 111 | export type CacheUpdaterTypePolicies = { 112 | Query: TypePolicy; 113 | User: TypePolicy; 114 | [__typename: string]: TypePolicy; 115 | }; 116 | 117 | export const withCacheUpdater = (typePolicies: CacheUpdaterTypePolicies) => 118 | withCacheUpdaterInternal({ 119 | paginationMetaList, 120 | deleteRecordMetaList, 121 | typePolicies, 122 | });`, 123 | ); 124 | expect(result.prepend).toStrictEqual([ 125 | "import { TypePolicy } from '@apollo/client';", 126 | "import { withCacheUpdaterInternal } from '@kazekyo/nau';", 127 | ]); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/plugins/cache-updater-support/config.ts: -------------------------------------------------------------------------------- 1 | import { ClientSideBasePluginConfig, RawClientSideBasePluginConfig } from '@graphql-codegen/visitor-plugin-common'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 4 | export interface PaginationConfig {} 5 | 6 | export interface PaginationRawPluginConfig extends RawClientSideBasePluginConfig, PaginationConfig {} 7 | export interface PaginationPluginConfig extends ClientSideBasePluginConfig, PaginationConfig {} 8 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/plugins/cache-updater-support/index.ts: -------------------------------------------------------------------------------- 1 | import { PluginFunction, PluginValidateFn, Types } from '@graphql-codegen/plugin-helpers'; 2 | import { LoadedFragment } from '@graphql-codegen/visitor-plugin-common'; 3 | import { concatAST, FragmentDefinitionNode, GraphQLSchema, Kind, TypeInfo, visit, visitWithTypeInfo } from 'graphql'; 4 | import { extname } from 'path'; 5 | import { nonNullable } from '../../utils/nonNullable'; 6 | import { PaginationPluginConfig, PaginationRawPluginConfig } from './config'; 7 | import { PaginationVisitor } from './visitor'; 8 | 9 | export const plugin: PluginFunction< 10 | PaginationRawPluginConfig & { documentFiles: Types.DocumentFile[] }, 11 | Types.ComplexPluginOutput 12 | > = (schema, _, config) => { 13 | const { documentFiles } = config; 14 | const documents = documentFiles.map((file) => file.document).filter(nonNullable); 15 | const allAst = concatAST(documents); 16 | 17 | const allFragments: LoadedFragment[] = [ 18 | ...(allAst.definitions.filter((d) => d.kind === Kind.FRAGMENT_DEFINITION) as FragmentDefinitionNode[]).map( 19 | (fragmentDef) => ({ 20 | node: fragmentDef, 21 | name: fragmentDef.name.value, 22 | onType: fragmentDef.typeCondition.name.value, 23 | isExternal: false, 24 | }), 25 | ), 26 | ...(config.externalFragments || []), 27 | ]; 28 | const typeInfo = new TypeInfo(schema); 29 | const visitor = new PaginationVisitor(schema, allFragments, config, documentFiles, typeInfo); 30 | 31 | visit( 32 | allAst, 33 | visitWithTypeInfo(typeInfo, { Directive: visitor.Directive.bind(visitor), Field: visitor.Field.bind(visitor) }), 34 | ); 35 | 36 | return { 37 | prepend: visitor.getImports(), 38 | content: visitor.getContent(), 39 | }; 40 | }; 41 | 42 | export const validate: PluginValidateFn = ( 43 | schema: GraphQLSchema, 44 | documents: Types.DocumentFile[], 45 | config: PaginationPluginConfig, 46 | outputFile: string, 47 | ) => { 48 | if (extname(outputFile) !== '.tsx' && extname(outputFile) !== '.ts') { 49 | throw new Error(`Plugin "nau.cacheUpdaterSupport" requires extension to be ".tsx" or ".ts"!`); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/preset.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { validateGraphQlDocuments } from '@graphql-tools/utils'; 3 | import { paginationDirectiveValidationRule } from '@kazekyo/nau-config'; 4 | import { 5 | RelayArgumentsOfCorrectType, 6 | RelayDefaultValueOfCorrectType, 7 | RelayKnownArgumentNames, 8 | RelayNoUnusedArguments, 9 | } from '@relay-graphql-js/validation-rules'; 10 | import { ASTVisitor, buildASTSchema, GraphQLSchema, specifiedRules, ValidationContext, ValidationRule } from 'graphql'; 11 | import cloneDeep from 'lodash.clonedeep'; 12 | import * as paginationPlugin from './plugins/cache-updater-support'; 13 | import { PresetConfig } from './presetConfig'; 14 | import { addCustomClientDirective } from './schemaTransforms/addClientDirective'; 15 | import { addConnectionId } from './schemaTransforms/addConnectionId'; 16 | import { transform as addFieldsForAddingNode } from './transforms/addFieldsForAddingNode'; 17 | import { transform as addPaginationFields } from './transforms/addPaginationFields'; 18 | import { transform as fixVariableNotDefinedInRoot } from './transforms/fixVariableNotDefinedInRoot'; 19 | import { transform as generateRefetchQuery } from './transforms/generateRefetchQuery'; 20 | import { transform as passArgumentValueToFragment } from './transforms/passArgumentValueToFragment'; 21 | import { transform as removeCustomDirective } from './transforms/removeCustomDirective'; 22 | import { nonNullable } from './utils/nonNullable'; 23 | 24 | const transformDocuments = ({ 25 | documentFiles, 26 | }: { 27 | documentFiles: Types.DocumentFile[]; 28 | }): { documentFiles: Types.DocumentFile[] } => { 29 | let result = { documentFiles }; 30 | [ 31 | passArgumentValueToFragment, 32 | generateRefetchQuery, 33 | fixVariableNotDefinedInRoot, 34 | addPaginationFields, 35 | addFieldsForAddingNode, 36 | ].forEach((transformFunc) => { 37 | result = transformFunc(result); 38 | }); 39 | return result; 40 | }; 41 | 42 | const transformSchema = (schema: GraphQLSchema, documentFiles: Types.DocumentFile[]): GraphQLSchema => { 43 | let result = schema; 44 | [addCustomClientDirective, addConnectionId].forEach((transform) => { 45 | result = transform(result, documentFiles).schema; 46 | }); 47 | return result; 48 | }; 49 | 50 | const validationRules = (): ValidationRule[] => { 51 | const ignored = [ 52 | 'NoUnusedFragmentsRule', 53 | 'NoUnusedVariablesRule', 54 | 'KnownArgumentNamesRule', 55 | 'NoUndefinedVariablesRule', 56 | ]; 57 | const v4ignored = ignored.map((rule) => rule.replace(/Rule$/, '')); 58 | 59 | const rules = specifiedRules.filter( 60 | (f: (context: ValidationContext) => ASTVisitor) => !ignored.includes(f.name) && !v4ignored.includes(f.name), 61 | ); 62 | return [ 63 | ...rules, 64 | RelayArgumentsOfCorrectType, 65 | RelayDefaultValueOfCorrectType, 66 | RelayNoUnusedArguments, 67 | RelayKnownArgumentNames, 68 | paginationDirectiveValidationRule, 69 | ] as unknown[] as ValidationRule[]; 70 | }; 71 | 72 | export const preset: Types.OutputPreset = { 73 | buildGeneratesSection: (options) => { 74 | const originalGraphQLSchema: GraphQLSchema = options.schemaAst 75 | ? options.schemaAst 76 | : buildASTSchema(options.schema, options.config); 77 | 78 | const schemaObject = transformSchema(originalGraphQLSchema, options.documents); 79 | 80 | const errors = validateGraphQlDocuments( 81 | schemaObject, 82 | options.documents.map((d) => d.document).filter(nonNullable), 83 | validationRules(), 84 | ); 85 | if (errors.length > 0) { 86 | throw new Error( 87 | `GraphQL Document Validation failed with ${errors.length} errors; 88 | ${errors.map((error, index) => `Error ${index}: ${error.stack || ''}`).join('\n\n')}`, 89 | ); 90 | } 91 | 92 | const transformedObject = transformDocuments({ documentFiles: cloneDeep(options.documents) }); 93 | 94 | let pluginMap = options.pluginMap; 95 | let plugins = options.plugins; 96 | const { generateTypeScriptCode } = options.presetConfig; 97 | if (generateTypeScriptCode) { 98 | pluginMap = { [`nau-pagination-code`]: paginationPlugin, ...pluginMap }; 99 | plugins = [ 100 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 101 | { [`nau-pagination-code`]: { documentFiles: cloneDeep(transformedObject.documentFiles) } }, 102 | ...plugins, 103 | ]; 104 | } 105 | 106 | const { documentFiles } = removeCustomDirective({ documentFiles: transformedObject.documentFiles }); 107 | 108 | const result = [ 109 | { 110 | filename: options.baseOutputDir, 111 | plugins: plugins, 112 | pluginMap: pluginMap, 113 | config: options.config, 114 | schema: options.schema, 115 | schemaAst: schemaObject, 116 | documents: documentFiles, 117 | }, 118 | ]; 119 | return result; 120 | }, 121 | }; 122 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/presetConfig.ts: -------------------------------------------------------------------------------- 1 | export type PresetConfig = { 2 | /** 3 | * @name generateTypeScriptCode 4 | * @type boolean 5 | * @default false 6 | * @description Optional, generate TypeScript code. 7 | * 8 | * @example 9 | * ```yml 10 | * generates: 11 | * src/: 12 | * preset: @kazekyo/nau-graphql-codegen-preset 13 | * presetConfig: 14 | * generateTypeScriptCode: true 15 | * plugins: 16 | * - typescript 17 | * - typescript-operations 18 | * - typescript-react-apollo 19 | * ``` 20 | */ 21 | generateTypeScriptCode?: boolean; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/schemaTransforms/__tests__/addConnectionId.test.ts: -------------------------------------------------------------------------------- 1 | import { parse, printSchema } from 'graphql'; 2 | import { testGraphqlSchema } from '../../utils/testing/utils'; 3 | import { addConnectionId } from '../addConnectionId'; 4 | describe('addConnectionId', () => { 5 | const documentFiles = [ 6 | { 7 | document: parse(/* GraphQL */ ` 8 | query TestQuery($cursor1: String, $cursor2: String, $cursor3: String) { 9 | itemsOnRoot: items(first: 1, after: $cursor1, keyword: "test") @pagination { 10 | edges { 11 | node { 12 | id 13 | __typename 14 | } 15 | cursor 16 | } 17 | pageInfo { 18 | hasNextPage 19 | endCursor 20 | } 21 | } 22 | viewer { 23 | id 24 | ...Fragment_user 25 | myItems: items(first: 1, after: $cursor2, keyword: "test2") @pagination { 26 | edges { 27 | node { 28 | name 29 | id 30 | __typename 31 | } 32 | cursor 33 | } 34 | pageInfo { 35 | hasNextPage 36 | endCursor 37 | } 38 | } 39 | } 40 | } 41 | fragment Fragment_user on User { 42 | id 43 | itemsOnFragment: items(first: 1, after: $cursor2, keyword: "test3") @pagination { 44 | edges { 45 | node { 46 | name 47 | id 48 | __typename 49 | } 50 | cursor 51 | } 52 | pageInfo { 53 | hasNextPage 54 | endCursor 55 | } 56 | } 57 | } 58 | `), 59 | }, 60 | ]; 61 | it('adds the connectionId field to the connection type with @pagination attached', () => { 62 | const before = ` 63 | """A connection to a list of items.""" 64 | type ItemConnection { 65 | """A list of edges.""" 66 | edges: [ItemEdge] 67 | 68 | """Information to aid in pagination.""" 69 | pageInfo: PageInfo! 70 | }`; 71 | expect(printSchema(testGraphqlSchema)).toContain(before); 72 | const result = addConnectionId(testGraphqlSchema, documentFiles); 73 | const after = ` 74 | """A connection to a list of items.""" 75 | type ItemConnection { 76 | """A list of edges.""" 77 | edges: [ItemEdge] 78 | 79 | """Information to aid in pagination.""" 80 | pageInfo: PageInfo! 81 | 82 | """Information of the connection for a client""" 83 | _connectionId: String! 84 | }`; 85 | expect(printSchema(result.schema)).toContain(after); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/schemaTransforms/addClientDirective.ts: -------------------------------------------------------------------------------- 1 | import { extendSchema, GraphQLSchema, parse } from 'graphql'; 2 | import { clientDirectives } from '@kazekyo/nau-config'; 3 | 4 | export const addCustomClientDirective = (schema: GraphQLSchema): { schema: GraphQLSchema } => { 5 | const currentDirectives = schema.getDirectives(); 6 | const additionalDirectives = Object.entries(clientDirectives) 7 | .filter(([key, _]) => !currentDirectives.find((d) => d.name === key)) 8 | .map(([_, value]) => value); 9 | 10 | if (additionalDirectives.length === 0) { 11 | return { schema }; 12 | } 13 | 14 | return { schema: extendSchema(schema, parse(additionalDirectives.join('\n'))) }; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/schemaTransforms/addConnectionId.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { PAGINATION_DIRECTIVE_NAME } from '@kazekyo/nau'; 3 | import { 4 | concatAST, 5 | extendSchema, 6 | FieldDefinitionNode, 7 | GraphQLSchema, 8 | Kind, 9 | NameNode, 10 | ObjectTypeExtensionNode, 11 | TypeInfo, 12 | visit, 13 | visitWithTypeInfo, 14 | } from 'graphql'; 15 | import { uniq } from 'lodash'; 16 | import { getConnectionType, getEdgeType, getNodeType } from '../utils/graphqlSchema'; 17 | import { nonNullable } from '../utils/nonNullable'; 18 | 19 | export const addConnectionId = ( 20 | schema: GraphQLSchema, 21 | documentFiles: Types.DocumentFile[], 22 | ): { schema: GraphQLSchema } => { 23 | const documents = documentFiles.map((file) => file.document).filter(nonNullable); 24 | const allAst = concatAST(documents); 25 | 26 | const paginationTypes: string[] = []; 27 | const typeInfo = new TypeInfo(schema); 28 | visit( 29 | allAst, 30 | visitWithTypeInfo(typeInfo, { 31 | Field: { 32 | leave(fieldNode) { 33 | if (!fieldNode.directives) return; 34 | const paginationDirective = fieldNode.directives.find( 35 | (directive) => directive.name.value === PAGINATION_DIRECTIVE_NAME, 36 | ); 37 | if (!paginationDirective) return; 38 | 39 | const connectionType = getConnectionType({ type: typeInfo.getType() }); 40 | if (!connectionType) return; 41 | const edgeType = getEdgeType({ connectionType, schema }); 42 | if (!edgeType) return; 43 | const nodeType = getNodeType({ edgeType, schema }); 44 | if (!nodeType) return; 45 | 46 | paginationTypes.push(connectionType.name); 47 | }, 48 | }, 49 | }), 50 | ); 51 | 52 | const definitions = uniq(paginationTypes).map((typename) => generateExtendConnectionType(typename)); 53 | const result = extendSchema(schema, { kind: Kind.DOCUMENT, definitions }); 54 | 55 | return { schema: result }; 56 | }; 57 | 58 | const generateExtendConnectionType = (connectionTypeName: string): ObjectTypeExtensionNode => { 59 | const name: NameNode = { 60 | kind: Kind.NAME, 61 | value: connectionTypeName, 62 | }; 63 | const field: FieldDefinitionNode = { 64 | kind: Kind.FIELD_DEFINITION, 65 | name: { 66 | kind: Kind.NAME, 67 | value: '_connectionId', 68 | }, 69 | description: { 70 | kind: Kind.STRING, 71 | value: 'Information of the connection for a client', 72 | }, 73 | type: { 74 | kind: Kind.NON_NULL_TYPE, 75 | type: { 76 | kind: Kind.NAMED_TYPE, 77 | name: { 78 | kind: Kind.NAME, 79 | value: 'String', 80 | }, 81 | }, 82 | }, 83 | }; 84 | const extentions: ObjectTypeExtensionNode = { 85 | kind: Kind.OBJECT_TYPE_EXTENSION, 86 | name: name, 87 | fields: [field], 88 | }; 89 | return extentions; 90 | }; 91 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/__tests__/addFieldsForAddingNode.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { printDocuments } from '../../utils/testing/utils'; 3 | import { transform } from '../addFieldsForAddingNode'; 4 | 5 | describe('transform', () => { 6 | it('adds fileds for @prependNode', () => { 7 | const document = parse(/* GraphQL */ ` 8 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 9 | addItem(input: $input) { 10 | item @prependNode(connections: $connections) { 11 | name 12 | } 13 | } 14 | } 15 | 16 | subscription ItemAddedSubscription($connections: [String!]!) { 17 | itemAdded { 18 | item @prependNode(connections: $connections) { 19 | name 20 | } 21 | } 22 | } 23 | `); 24 | const expectedDocument = parse(/* GraphQL */ ` 25 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 26 | addItem(input: $input) { 27 | item @prependNode(connections: $connections) { 28 | name 29 | id 30 | __typename 31 | } 32 | } 33 | } 34 | 35 | subscription ItemAddedSubscription($connections: [String!]!) { 36 | itemAdded { 37 | item @prependNode(connections: $connections) { 38 | name 39 | id 40 | __typename 41 | } 42 | } 43 | } 44 | `); 45 | const result = transform({ documentFiles: [{ document }] }); 46 | 47 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 48 | }); 49 | 50 | it('adds fileds for @appendNode', () => { 51 | const document = parse(/* GraphQL */ ` 52 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 53 | addItem(input: $input) { 54 | item @appendNode(connections: $connections) { 55 | name 56 | } 57 | } 58 | } 59 | 60 | subscription ItemAddedSubscription($connections: [String!]!) { 61 | itemAdded { 62 | item @appendNode(connections: $connections) { 63 | name 64 | } 65 | } 66 | } 67 | `); 68 | const expectedDocument = parse(/* GraphQL */ ` 69 | mutation AddItemMutation($input: AddItemInput!, $connections: [String!]!) { 70 | addItem(input: $input) { 71 | item @appendNode(connections: $connections) { 72 | name 73 | id 74 | __typename 75 | } 76 | } 77 | } 78 | 79 | subscription ItemAddedSubscription($connections: [String!]!) { 80 | itemAdded { 81 | item @appendNode(connections: $connections) { 82 | name 83 | id 84 | __typename 85 | } 86 | } 87 | } 88 | `); 89 | const result = transform({ documentFiles: [{ document }] }); 90 | 91 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/__tests__/generateRefetchQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { printDocuments } from '../../utils/testing/utils'; 3 | import { transform } from '../generateRefetchQuery'; 4 | 5 | describe('transform', () => { 6 | it('generates the refetch query', () => { 7 | const document = parse(/* GraphQL */ ` 8 | query TestQuery { 9 | viewer { 10 | ...UserFragment @arguments(count: 5) 11 | } 12 | } 13 | fragment UserFragment on User 14 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 15 | @refetchable(queryName: "TestRefetchQuery") { 16 | id 17 | items(first: $count, after: $cursor) { 18 | edges { 19 | node { 20 | id 21 | } 22 | } 23 | } 24 | } 25 | `); 26 | const expectedDocument = parse(/* GraphQL */ ` 27 | query TestQuery { 28 | viewer { 29 | ...UserFragment @arguments(count: 5) 30 | } 31 | } 32 | fragment UserFragment on User 33 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 34 | @refetchable(queryName: "TestRefetchQuery") { 35 | id 36 | items(first: $count, after: $cursor) { 37 | edges { 38 | node { 39 | id 40 | } 41 | } 42 | } 43 | } 44 | query TestRefetchQuery($count: Int = 2, $cursor: String, $id: ID!) { 45 | node(id: $id) { 46 | id 47 | __typename 48 | ...UserFragment 49 | } 50 | } 51 | `); 52 | const result = transform({ documentFiles: [{ document }] }); 53 | 54 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 55 | }); 56 | 57 | it('collects all variables from fragments and defines them as variables of the query', () => { 58 | const document = parse(/* GraphQL */ ` 59 | query TestQuery { 60 | viewer { 61 | ...UserFragment1 62 | } 63 | } 64 | fragment UserFragment1 on User 65 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 66 | @refetchable(queryName: "TestRefetchQuery") { 67 | ...UserFragment2 68 | items(first: $count, after: $cursor) { 69 | edges { 70 | node { 71 | id 72 | } 73 | } 74 | } 75 | } 76 | fragment UserFragment2 on User 77 | @argumentDefinitions( 78 | iconWidth: { type: "Int", defaultValue: 100 } 79 | iconHeight: { type: "Int", defaultValue: 100 } 80 | ) { 81 | id 82 | iconImage(width: $iconWidth, height: $iconHeight) 83 | } 84 | `); 85 | 86 | const expectedDocument = parse(/* GraphQL */ ` 87 | query TestQuery { 88 | viewer { 89 | ...UserFragment1 90 | } 91 | } 92 | fragment UserFragment1 on User 93 | @argumentDefinitions(count: { type: "Int", defaultValue: 2 }, cursor: { type: "String" }) 94 | @refetchable(queryName: "TestRefetchQuery") { 95 | ...UserFragment2 96 | items(first: $count, after: $cursor) { 97 | edges { 98 | node { 99 | id 100 | } 101 | } 102 | } 103 | } 104 | fragment UserFragment2 on User 105 | @argumentDefinitions( 106 | iconWidth: { type: "Int", defaultValue: 100 } 107 | iconHeight: { type: "Int", defaultValue: 100 } 108 | ) { 109 | id 110 | iconImage(width: $iconWidth, height: $iconHeight) 111 | } 112 | query TestRefetchQuery( 113 | $count: Int = 2 114 | $cursor: String 115 | $iconWidth: Int = 100 116 | $iconHeight: Int = 100 117 | $id: ID! 118 | ) { 119 | node(id: $id) { 120 | id 121 | __typename 122 | ...UserFragment1 123 | } 124 | } 125 | `); 126 | const result = transform({ documentFiles: [{ document }] }); 127 | 128 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/__tests__/passArgumentValueToFragment.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { printDocuments } from '../../utils/testing/utils'; 3 | import { transform } from '../passArgumentValueToFragment'; 4 | 5 | describe('transform', () => { 6 | it('changes the documents', () => { 7 | const document = parse(/* GraphQL */ ` 8 | query TestQuery($testNumber: Int!) { 9 | viewer { 10 | ...UserFragment @arguments(arg1: 1, arg2: "2", arg3: $testNumber) 11 | } 12 | } 13 | fragment UserFragment on User 14 | @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }, arg2: { type: "String" }, arg3: { type: "Int!" }) { 15 | id 16 | item(itemArg1: $arg1, itemArg2: $arg2, itemArg3: $arg3) { 17 | id 18 | name 19 | } 20 | } 21 | `); 22 | const expectedDocument = parse(/* GraphQL */ ` 23 | query TestQuery($testNumber: Int!) { 24 | viewer { 25 | ...UserFragment_bi8xLGFyZzE6MSxhcmcyOiIyIixhcmczOiR0ZXN0TnVtYmVy 26 | @arguments(arg1: 1, arg2: "2", arg3: $testNumber) 27 | } 28 | } 29 | fragment UserFragment_bi8xLGFyZzE6MSxhcmcyOiIyIixhcmczOiR0ZXN0TnVtYmVy on User 30 | @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }, arg2: { type: "String" }, arg3: { type: "Int!" }) { 31 | id 32 | item(itemArg1: 1, itemArg2: "2", itemArg3: $testNumber) { 33 | id 34 | name 35 | } 36 | } 37 | `); 38 | const result = transform({ documentFiles: [{ document }] }); 39 | 40 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 41 | }); 42 | 43 | it('copies the fragment definition with @arguments attached if there are both a spreading fragment with @arguments attached and a spreading fragment without @arguments attached', () => { 44 | const document = parse(/* GraphQL */ ` 45 | query TestQuery1 { 46 | viewer { 47 | ...UserFragment @arguments(arg1: 1) 48 | } 49 | } 50 | query TestQuery2 { 51 | viewer { 52 | ...UserFragment 53 | } 54 | } 55 | query TestQuery3 { 56 | viewer { 57 | ...UserFragment 58 | } 59 | } 60 | query TestQuery4 { 61 | viewer { 62 | ...UserFragment_wrapped 63 | } 64 | } 65 | fragment UserFragment on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 66 | id 67 | item(itemArg1: $arg1) { 68 | id 69 | } 70 | } 71 | fragment UserFragment_wrapped on User { 72 | ...UserFragment @arguments(arg1: 2) 73 | } 74 | `); 75 | const expectedDocument = parse(/* GraphQL */ ` 76 | query TestQuery1 { 77 | viewer { 78 | ...UserFragment_bi8xLGFyZzE6MQ @arguments(arg1: 1) 79 | } 80 | } 81 | query TestQuery2 { 82 | viewer { 83 | ...UserFragment 84 | } 85 | } 86 | query TestQuery3 { 87 | viewer { 88 | ...UserFragment 89 | } 90 | } 91 | query TestQuery4 { 92 | viewer { 93 | ...UserFragment_wrapped 94 | } 95 | } 96 | fragment UserFragment_bi8xLGFyZzE6MQ on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 97 | id 98 | item(itemArg1: 1) { 99 | id 100 | } 101 | } 102 | fragment UserFragment on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 103 | id 104 | item(itemArg1: $arg1) { 105 | id 106 | } 107 | } 108 | fragment UserFragment_bi8xLGFyZzE6Mg on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 109 | id 110 | item(itemArg1: 2) { 111 | id 112 | } 113 | } 114 | fragment UserFragment_wrapped on User { 115 | ...UserFragment_bi8xLGFyZzE6Mg @arguments(arg1: 2) 116 | } 117 | `); 118 | const result = transform({ documentFiles: [{ document }] }); 119 | 120 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 121 | }); 122 | 123 | it('deletes the original fragment definition if @arguments is attached to all spreading fragments', () => { 124 | const document = parse(/* GraphQL */ ` 125 | query TestQuery1 { 126 | viewer { 127 | ...UserFragment @arguments(arg1: 1) 128 | } 129 | } 130 | query TestQuery2 { 131 | viewer { 132 | ...UserFragment @arguments(arg1: 2) 133 | } 134 | } 135 | fragment UserFragment on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 136 | id 137 | item(itemArg1: $arg1) { 138 | id 139 | } 140 | } 141 | `); 142 | const expectedDocument = parse(/* GraphQL */ ` 143 | query TestQuery1 { 144 | viewer { 145 | ...UserFragment_bi8xLGFyZzE6MQ @arguments(arg1: 1) 146 | } 147 | } 148 | query TestQuery2 { 149 | viewer { 150 | ...UserFragment_bi8xLGFyZzE6Mg @arguments(arg1: 2) 151 | } 152 | } 153 | fragment UserFragment_bi8xLGFyZzE6MQ on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 154 | id 155 | item(itemArg1: 1) { 156 | id 157 | } 158 | } 159 | fragment UserFragment_bi8xLGFyZzE6Mg on User @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) { 160 | id 161 | item(itemArg1: 2) { 162 | id 163 | } 164 | } 165 | `); 166 | const result = transform({ documentFiles: [{ document }] }); 167 | 168 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/__tests__/removeCustomDirective.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { printDocuments } from '../../utils/testing/utils'; 3 | import { transform } from '../removeCustomDirective'; 4 | 5 | describe('transform', () => { 6 | it('removes custom directives', () => { 7 | const document = parse(/* GraphQL */ ` 8 | query TestQuery($cursor: String) { 9 | viewer { 10 | ...UserFragment @arguments(arg: 1) 11 | } 12 | } 13 | fragment UserFragment on User 14 | @refetchable(queryName: "RefetchQuery") 15 | @argumentDefinitions(arg: { type: "Int", defaultValue: 10 }) { 16 | id 17 | items(first: 1, after: $cursor) @pagination { 18 | edges { 19 | node { 20 | id 21 | } 22 | } 23 | } 24 | } 25 | `); 26 | const expectedDocument = parse(/* GraphQL */ ` 27 | query TestQuery($cursor: String) { 28 | viewer { 29 | ...UserFragment 30 | } 31 | } 32 | fragment UserFragment on User { 33 | id 34 | items(first: 1, after: $cursor) { 35 | edges { 36 | node { 37 | id 38 | } 39 | } 40 | } 41 | } 42 | `); 43 | const result = transform({ documentFiles: [{ document }] }); 44 | 45 | expect(printDocuments(result.documentFiles)).toBe(printDocuments([{ document: expectedDocument }])); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/addFieldsForAddingNode.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { INSERT_NODE_DIRECTIVE_NAMES } from '@kazekyo/nau'; 3 | import { visit } from 'graphql'; 4 | import { 5 | addFieldToSelectionSetNodeWithoutDuplication, 6 | getDirectives, 7 | idField, 8 | typenameField, 9 | } from '../utils/graphqlAST'; 10 | 11 | export const transform = ({ 12 | documentFiles, 13 | }: { 14 | documentFiles: Types.DocumentFile[]; 15 | }): { documentFiles: Types.DocumentFile[] } => { 16 | const files = documentFiles.map((file) => { 17 | if (!file.document) return file; 18 | 19 | file.document = visit(file.document, { 20 | Field: { 21 | leave(fieldNode) { 22 | if (!fieldNode.directives || !fieldNode.selectionSet) return fieldNode; 23 | const directives = getDirectives({ node: fieldNode, directiveNames: INSERT_NODE_DIRECTIVE_NAMES }); 24 | if (directives.length === 0) return fieldNode; 25 | 26 | const selectionSet = addFieldToSelectionSetNodeWithoutDuplication({ 27 | selectionSetNode: fieldNode.selectionSet, 28 | additionalFields: [idField, typenameField], 29 | }); 30 | 31 | return { ...fieldNode, selectionSet }; 32 | }, 33 | }, 34 | }); 35 | return file; 36 | }); 37 | 38 | return { documentFiles: files }; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/generateRefetchQuery.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { ARGUMENT_DEFINITIONS_DIRECTIVE_NAME, REFETCHABLE_DIRECTIVE_NAME } from '@kazekyo/nau'; 3 | import { 4 | DocumentNode, 5 | FragmentDefinitionNode, 6 | Kind, 7 | parse, 8 | parseType, 9 | print, 10 | VariableDefinitionNode, 11 | visit, 12 | } from 'graphql'; 13 | import { uniqWith } from 'lodash'; 14 | import { getFragmentDefinitionsByDocumentFiles } from '../utils/graphqlAST'; 15 | import { nonNullable } from '../utils/nonNullable'; 16 | import { getArgumentDefinitionDataList } from './util'; 17 | 18 | export const transform = ({ 19 | documentFiles, 20 | }: { 21 | documentFiles: Types.DocumentFile[]; 22 | }): { documentFiles: Types.DocumentFile[] } => { 23 | const fragmentDefinitions = getFragmentDefinitionsByDocumentFiles(documentFiles); 24 | 25 | const refetchQueryMaterials: { 26 | [queryName: string]: { variableDefinitions: VariableDefinitionNode[]; innerFragmentName: string }; 27 | } = {}; 28 | 29 | fragmentDefinitions.forEach((definition) => { 30 | visit(definition, { 31 | Directive: { 32 | enter(node) { 33 | if (node.name.value !== REFETCHABLE_DIRECTIVE_NAME || !node.arguments) return false; 34 | 35 | const argument = node.arguments[0]; 36 | if (argument.value.kind !== Kind.STRING) return false; 37 | const queryName = argument.value.value; 38 | 39 | const collectResult = collectVariableDefinitions({ 40 | fragmentDefinition: definition, 41 | allFragmentDefinitions: fragmentDefinitions, 42 | collectedFragmentNames: [], 43 | }); 44 | 45 | refetchQueryMaterials[queryName] = { 46 | innerFragmentName: definition.name.value, 47 | variableDefinitions: uniqWith( 48 | collectResult.variableDefinitions, 49 | (a, b) => a.variable.name.value === b.variable.name.value, 50 | ), 51 | }; 52 | return false; 53 | }, 54 | }, 55 | }); 56 | }); 57 | 58 | const refetchDocumentNodes: DocumentNode[] = Object.entries(refetchQueryMaterials) 59 | .map(([queryName, material]) => { 60 | const documentNode = parse(queryString({ queryName, fragmentName: material.innerFragmentName })); 61 | if (documentNode.definitions[0].kind !== 'OperationDefinition') return null; 62 | const refetchDocumentNode: DocumentNode = { 63 | ...documentNode, 64 | definitions: [ 65 | { 66 | ...documentNode.definitions[0], 67 | variableDefinitions: [ 68 | ...material.variableDefinitions, 69 | { 70 | kind: Kind.VARIABLE_DEFINITION, 71 | type: parseType('ID!'), 72 | variable: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: 'id' } }, 73 | }, 74 | ], 75 | }, 76 | ], 77 | }; 78 | return refetchDocumentNode; 79 | }) 80 | .filter(nonNullable); 81 | 82 | const files = [ 83 | ...documentFiles, 84 | ...refetchDocumentNodes.map((documentNode) => { 85 | return { 86 | document: documentNode, 87 | rawSDL: print(documentNode), 88 | location: 'generated by Nau', 89 | }; 90 | }), 91 | ]; 92 | 93 | return { documentFiles: files }; 94 | }; 95 | 96 | const queryString = ({ queryName, fragmentName }: { queryName: string; fragmentName: string }): string => { 97 | return ` 98 | query ${queryName} { 99 | node(id: $id) { 100 | id 101 | __typename 102 | ...${fragmentName} 103 | } 104 | }`; 105 | }; 106 | 107 | const collectVariableDefinitions = ({ 108 | fragmentDefinition, 109 | allFragmentDefinitions, 110 | collectedFragmentNames, 111 | }: { 112 | fragmentDefinition: FragmentDefinitionNode; 113 | allFragmentDefinitions: FragmentDefinitionNode[]; 114 | collectedFragmentNames: string[]; 115 | }): { variableDefinitions: VariableDefinitionNode[]; collectedFragmentNames: string[] } => { 116 | let variableDefinitions: VariableDefinitionNode[] = []; 117 | let fragmentNames = [...collectedFragmentNames, fragmentDefinition.name.value]; 118 | 119 | // Collect variable definitions 120 | visit(fragmentDefinition, { 121 | Directive: { 122 | enter(node) { 123 | if (node.name.value !== ARGUMENT_DEFINITIONS_DIRECTIVE_NAME) return; 124 | const argumentDataList = getArgumentDefinitionDataList(node); 125 | argumentDataList.forEach((data) => 126 | variableDefinitions.push({ 127 | kind: Kind.VARIABLE_DEFINITION, 128 | type: data.type, 129 | variable: { kind: Kind.VARIABLE, name: data.name }, 130 | defaultValue: data.defaultValue, 131 | }), 132 | ); 133 | return false; 134 | }, 135 | }, 136 | }); 137 | 138 | // Visit nested fragments 139 | visit(fragmentDefinition, { 140 | FragmentSpread: { 141 | enter(node) { 142 | const fragmentName = node.name.value; 143 | 144 | if (fragmentNames.includes(fragmentName)) return false; 145 | 146 | const nestedFragment = allFragmentDefinitions.find((definition) => definition.name.value === fragmentName); 147 | if (!nestedFragment) return false; 148 | 149 | const nestedFragmentResult = collectVariableDefinitions({ 150 | fragmentDefinition: nestedFragment, 151 | allFragmentDefinitions, 152 | collectedFragmentNames: fragmentNames, 153 | }); 154 | 155 | variableDefinitions = [...variableDefinitions, ...nestedFragmentResult.variableDefinitions]; 156 | fragmentNames = [...fragmentNames, ...nestedFragmentResult.collectedFragmentNames]; 157 | return false; 158 | }, 159 | }, 160 | }); 161 | 162 | return { variableDefinitions, collectedFragmentNames: fragmentNames }; 163 | }; 164 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/removeCustomDirective.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { 3 | ARGUMENTS_DIRECTIVE_NAME, 4 | ARGUMENT_DEFINITIONS_DIRECTIVE_NAME, 5 | PAGINATION_DIRECTIVE_NAME, 6 | REFETCHABLE_DIRECTIVE_NAME, 7 | } from '@kazekyo/nau'; 8 | import { visit } from 'graphql'; 9 | 10 | const DIRECTIVES = [ 11 | ARGUMENT_DEFINITIONS_DIRECTIVE_NAME, 12 | ARGUMENTS_DIRECTIVE_NAME, 13 | REFETCHABLE_DIRECTIVE_NAME, 14 | PAGINATION_DIRECTIVE_NAME, 15 | ]; 16 | 17 | export const transform = ({ 18 | documentFiles, 19 | }: { 20 | documentFiles: Types.DocumentFile[]; 21 | }): { documentFiles: Types.DocumentFile[] } => { 22 | const files = documentFiles.map((file) => { 23 | if (!file.document) return file; 24 | 25 | file.document = visit(file.document, { 26 | Directive: { 27 | enter(node) { 28 | if (DIRECTIVES.includes(node.name.value)) { 29 | return null; 30 | } 31 | }, 32 | }, 33 | }); 34 | return file; 35 | }); 36 | 37 | return { documentFiles: files }; 38 | }; 39 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/transforms/util.ts: -------------------------------------------------------------------------------- 1 | import { ARGUMENT_DEFINITIONS_DIRECTIVE_NAME } from '@kazekyo/nau'; 2 | import { ConstValueNode, DirectiveNode, FragmentDefinitionNode, Kind, NameNode, parseType, TypeNode } from 'graphql'; 3 | import { decode, encode } from 'js-base64'; 4 | 5 | export const mergeCustomizer = (objValue: unknown, srcValue: unknown): unknown => { 6 | if (Array.isArray(objValue) && Array.isArray(srcValue)) { 7 | return objValue.concat(srcValue) as unknown[]; 8 | } 9 | }; 10 | 11 | export const FRAGMENT_NAME_INFO_ID_1 = '1'; 12 | export const FRAGMENT_NAME_INFO_ID_2 = '2'; 13 | export const FRAGMENT_NAME_INFO_ID_3 = '3'; 14 | 15 | export const getUniqueFragmentName = (name: string, info: string): string => { 16 | const splitName = name.split('_'); 17 | const endStr = splitName.pop(); 18 | if (splitName.length === 1 || !endStr) return `${name}_${encode('n/' + info, true)}`; 19 | 20 | const decodedInfo = decode(endStr); 21 | const isAlreadyUniqueName = decodedInfo.startsWith('n/'); 22 | if (!isAlreadyUniqueName) return `${name}_${encode('n/' + info, true)}`; 23 | 24 | return `${splitName.join('_')}_${encode(decodedInfo + '/' + info, true)}`; 25 | }; 26 | 27 | export type ArgumentDefinitionData = { name: NameNode; type: TypeNode; defaultValue?: ConstValueNode }; 28 | export const getArgumentDefinitionDataList = (node: DirectiveNode): ArgumentDefinitionData[] => { 29 | if (node.name.value !== ARGUMENT_DEFINITIONS_DIRECTIVE_NAME) return []; 30 | if (!node.arguments || node.arguments.length === 0) return []; 31 | 32 | const list: ArgumentDefinitionData[] = []; 33 | 34 | // Example: node is @argumentDefinitions(arg1: { type: "Int", defaultValue: 10 }) 35 | node.arguments.forEach((argument) => { 36 | if (argument.value.kind !== 'ObjectValue') return; 37 | const name = argument.name; // Example: name is arg1 38 | 39 | const typeField = argument.value.fields.find((field) => field.name.value === 'type'); 40 | let typeString: string | undefined; 41 | if (typeField && typeField.value.kind === Kind.STRING) { 42 | typeString = typeField.value.value; // Example: typeString is "Int" 43 | } 44 | if (!typeString) return; 45 | 46 | const defaultValueField = argument.value.fields.find((field) => field.name.value === 'defaultValue'); 47 | const defaultValue = defaultValueField?.value; // Example: defaultValue is 10 48 | let defaultConstValue: ConstValueNode | undefined = undefined; 49 | if (defaultValue && defaultValue.kind !== Kind.VARIABLE) { 50 | defaultConstValue = defaultValue as ConstValueNode; 51 | } 52 | 53 | list.push({ name, type: parseType(typeString), defaultValue: defaultConstValue }); 54 | }); 55 | return list; 56 | }; 57 | 58 | // TODO : Delete 59 | export type ChangedFragments = { [originalFragmentName: string]: FragmentDefinitionNode[] }; 60 | export const addFragmentToChangedFragment = ({ 61 | originalFragmentName, 62 | changedFragments, 63 | changedFragmentDefinition, 64 | }: { 65 | originalFragmentName: string; 66 | changedFragments: ChangedFragments; 67 | changedFragmentDefinition: FragmentDefinitionNode; 68 | }): ChangedFragments => { 69 | const definitions = changedFragments[originalFragmentName] || []; 70 | if (definitions.find((definition) => definition.name.value === changedFragmentDefinition.name.value)) { 71 | return changedFragments; 72 | } 73 | definitions.push(changedFragmentDefinition); 74 | changedFragments[originalFragmentName] = definitions; 75 | return changedFragments; 76 | }; 77 | 78 | export const existsFragmentDefinitionInChangedFragments = ({ 79 | changedFragments, 80 | newFragmentName, 81 | }: { 82 | changedFragments: ChangedFragments; 83 | newFragmentName: string; 84 | }): boolean => { 85 | const fragmentDefinitions = Object.entries(changedFragments) 86 | .map(([_, fragments]) => fragments) 87 | .flat(); 88 | return !!fragmentDefinitions.find((definition) => definition.name.value === newFragmentName); 89 | }; 90 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/utils/graphqlAST.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { DirectiveName } from '@kazekyo/nau'; 3 | import { 4 | DirectiveNode, 5 | DocumentNode, 6 | FieldNode, 7 | FragmentDefinitionNode, 8 | Kind, 9 | OperationDefinitionNode, 10 | SelectionNode, 11 | SelectionSetNode, 12 | } from 'graphql'; 13 | import { nonNullable } from './nonNullable'; 14 | 15 | export const pageInfoField: FieldNode = { 16 | kind: Kind.FIELD, 17 | name: { kind: Kind.NAME, value: 'pageInfo' }, 18 | selectionSet: { 19 | kind: Kind.SELECTION_SET, 20 | selections: [ 21 | { kind: Kind.FIELD, name: { kind: Kind.NAME, value: 'hasNextPage' } }, 22 | { kind: Kind.FIELD, name: { kind: Kind.NAME, value: 'endCursor' } }, 23 | { kind: Kind.FIELD, name: { kind: Kind.NAME, value: 'hasPreviousPage' } }, 24 | { kind: Kind.FIELD, name: { kind: Kind.NAME, value: 'startCursor' } }, 25 | ], 26 | }, 27 | }; 28 | 29 | export const cursorField: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: 'cursor' } }; 30 | 31 | export const idField: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: 'id' } }; 32 | 33 | export const typenameField: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' } }; 34 | 35 | export const nodeField: FieldNode = { 36 | kind: Kind.FIELD, 37 | name: { kind: Kind.NAME, value: 'node' }, 38 | selectionSet: { 39 | kind: Kind.SELECTION_SET, 40 | selections: [idField, typenameField], 41 | }, 42 | }; 43 | 44 | export const edgesField: FieldNode = { 45 | kind: Kind.FIELD, 46 | name: { kind: Kind.NAME, value: 'edges' }, 47 | selectionSet: { 48 | kind: Kind.SELECTION_SET, 49 | selections: [nodeField, cursorField], 50 | }, 51 | }; 52 | 53 | export const connectionIdField: FieldNode = { 54 | kind: Kind.FIELD, 55 | name: { kind: Kind.NAME, value: '_connectionId' }, 56 | directives: [{ kind: Kind.DIRECTIVE, name: { kind: Kind.NAME, value: 'client' } }], 57 | }; 58 | 59 | export function getOperationDefinitions(doc: DocumentNode): OperationDefinitionNode[] { 60 | return doc.definitions.filter((definition) => definition.kind === 'OperationDefinition') as OperationDefinitionNode[]; 61 | } 62 | 63 | export const getFragmentDefinitions = (documentNode: DocumentNode): FragmentDefinitionNode[] => { 64 | return documentNode.definitions.filter( 65 | (definition): definition is FragmentDefinitionNode => definition.kind === 'FragmentDefinition', 66 | ); 67 | }; 68 | 69 | export const getFragmentDefinitionsByDocumentFiles = ( 70 | documentFiles: Types.DocumentFile[], 71 | ): FragmentDefinitionNode[] => { 72 | return documentFiles 73 | .map((file) => file.document?.definitions) 74 | .filter(nonNullable) 75 | .flat() 76 | .filter((definition): definition is FragmentDefinitionNode => definition.kind === Kind.FRAGMENT_DEFINITION); 77 | }; 78 | 79 | export const getFragmentDefinitionByName = ({ 80 | fragmentDefinitions, 81 | fragmentName, 82 | }: { 83 | fragmentDefinitions: FragmentDefinitionNode[]; 84 | fragmentName: string; 85 | }): FragmentDefinitionNode | undefined => { 86 | return fragmentDefinitions.find((definition) => definition.name.value === fragmentName); 87 | }; 88 | 89 | export const getDirectives = ({ 90 | node, 91 | directiveNames, 92 | }: { 93 | node: FieldNode | SelectionNode | null; 94 | directiveNames: DirectiveName[]; 95 | }): DirectiveNode[] => { 96 | const directives = node?.directives?.filter((directive) => 97 | directiveNames.find((name) => name === directive.name.value), 98 | ); 99 | if (!directives) return []; 100 | return directives; 101 | }; 102 | 103 | export const isSameNameFieldNode = ({ selection, name }: { selection: SelectionNode; name: string }): boolean => { 104 | return selection.kind === Kind.FIELD && selection.name.value === name; 105 | }; 106 | 107 | // Add the field, but do nothing if the field already exists 108 | export const addFieldWithoutDuplication = ({ 109 | fieldNode, 110 | additionalFields, 111 | }: { 112 | fieldNode: FieldNode; 113 | additionalFields: FieldNode[]; 114 | }): FieldNode => { 115 | if (!fieldNode.selectionSet) return fieldNode; 116 | const selectionSet = addFieldToSelectionSetNodeWithoutDuplication({ 117 | selectionSetNode: fieldNode.selectionSet, 118 | additionalFields, 119 | }); 120 | 121 | return { 122 | ...fieldNode, 123 | selectionSet, 124 | }; 125 | }; 126 | 127 | // Add the field, but do nothing if the field already exists 128 | export const addFieldToSelectionSetNodeWithoutDuplication = ({ 129 | selectionSetNode, 130 | additionalFields, 131 | }: { 132 | selectionSetNode: SelectionSetNode; 133 | additionalFields: FieldNode[]; 134 | }): SelectionSetNode => { 135 | const selections = selectionSetNode.selections; 136 | 137 | const fieldNodes = additionalFields.filter( 138 | (fieldNode) => !selections.find((selection) => isSameNameFieldNode({ selection, name: fieldNode.name.value })), 139 | ); 140 | if (fieldNodes.length === 0) return selectionSetNode; 141 | 142 | return { 143 | ...selectionSetNode, 144 | selections: [...selections, ...fieldNodes], 145 | }; 146 | }; 147 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/utils/graphqlSchema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType, GraphQLOutputType, GraphQLSchema, isListType, isNonNullType, isObjectType } from 'graphql'; 2 | import { getFieldDef } from 'graphql/execution/execute'; 3 | import { Maybe } from 'graphql/jsutils/Maybe'; 4 | import { edgesField, nodeField } from './graphqlAST'; 5 | 6 | export const getConnectionType = ({ type }: { type: Maybe }): GraphQLObjectType | undefined => { 7 | let connectionType = type; 8 | if (!connectionType) return; 9 | if (isNonNullType(connectionType)) { 10 | connectionType = connectionType.ofType as GraphQLObjectType; 11 | } 12 | if (!isObjectType(connectionType)) return; 13 | if (!connectionType.name.endsWith('Connection')) return; 14 | return connectionType; 15 | }; 16 | 17 | export const getEdgeType = ({ 18 | connectionType, 19 | schema, 20 | }: { 21 | connectionType: GraphQLObjectType; 22 | schema: GraphQLSchema; 23 | }): GraphQLObjectType | undefined => { 24 | const edgesFieldDef = getFieldDef(schema, connectionType, edgesField); 25 | if (!edgesFieldDef) return; 26 | 27 | let edgesType = edgesFieldDef.type; 28 | if (isNonNullType(edgesType)) { 29 | edgesType = edgesType.ofType; 30 | } 31 | if (!isListType(edgesType)) return; 32 | 33 | let edgeType = edgesType.ofType as Maybe; 34 | if (!edgeType) return; 35 | 36 | if (isNonNullType(edgeType)) { 37 | edgeType = edgeType.ofType; 38 | } 39 | if (!isObjectType(edgeType)) return; 40 | 41 | return edgeType; 42 | }; 43 | 44 | export const getNodeType = ({ 45 | edgeType, 46 | schema, 47 | }: { 48 | edgeType: GraphQLObjectType; 49 | schema: GraphQLSchema; 50 | }): GraphQLObjectType | undefined => { 51 | const nodeFieldDef = getFieldDef(schema, edgeType, nodeField); 52 | if (!nodeFieldDef) return; 53 | 54 | let nodeType = nodeFieldDef.type; 55 | if (!nodeType) return; 56 | 57 | if (isNonNullType(nodeType)) { 58 | nodeType = nodeType.ofType; 59 | } 60 | if (!isObjectType(nodeType)) return; 61 | 62 | return nodeType; 63 | }; 64 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/utils/nonNullable.ts: -------------------------------------------------------------------------------- 1 | export const nonNullable = (value: T): value is NonNullable => value != null; 2 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/utils/testing/example.graphql: -------------------------------------------------------------------------------- 1 | directive @arguments on FRAGMENT_SPREAD 2 | 3 | directive @argumentDefinitions on FRAGMENT_DEFINITION 4 | 5 | directive @refetchable(queryName: String!) on FRAGMENT_DEFINITION 6 | 7 | directive @pagination on FIELD 8 | 9 | directive @appendNode(connections: [String!]) on FIELD 10 | 11 | directive @prependNode(connections: [String!]) on FIELD 12 | 13 | directive @deleteRecord(typename: String!) on FIELD 14 | 15 | directive @client on FIELD 16 | 17 | input AddItemInput { 18 | clientMutationId: String 19 | itemName: String! 20 | userId: ID! 21 | } 22 | 23 | type AddItemPayload { 24 | clientMutationId: String 25 | item: Item! 26 | } 27 | 28 | type Item implements Node { 29 | """ 30 | The ID of an object 31 | """ 32 | id: ID! 33 | 34 | """ 35 | The name of the item. 36 | """ 37 | name: String! 38 | } 39 | 40 | type ItemAddedPayload { 41 | item: Item! 42 | } 43 | 44 | """ 45 | A connection to a list of items. 46 | """ 47 | type ItemConnection { 48 | """ 49 | A list of edges. 50 | """ 51 | edges: [ItemEdge] 52 | 53 | """ 54 | Information to aid in pagination. 55 | """ 56 | pageInfo: PageInfo! 57 | } 58 | 59 | """ 60 | An edge in a connection. 61 | """ 62 | type ItemEdge { 63 | """ 64 | A cursor for use in pagination 65 | """ 66 | cursor: String! 67 | 68 | """ 69 | The item at the end of the edge 70 | """ 71 | node: Item 72 | } 73 | 74 | type ItemRemovedPayload { 75 | """ 76 | The ID of an object 77 | """ 78 | id: ID! 79 | } 80 | 81 | type Mutation { 82 | addItem(input: AddItemInput!): AddItemPayload 83 | removeItem(input: RemoveItemInput!): RemoveItemPayload 84 | updateItem(input: UpdateItemInput!): UpdateItemPayload 85 | } 86 | 87 | """ 88 | An object with an ID 89 | """ 90 | interface Node { 91 | """ 92 | The id of the object. 93 | """ 94 | id: ID! 95 | } 96 | 97 | """ 98 | Information about pagination in a connection. 99 | """ 100 | type PageInfo { 101 | """ 102 | When paginating forwards, the cursor to continue. 103 | """ 104 | endCursor: String 105 | 106 | """ 107 | When paginating forwards, are there more items? 108 | """ 109 | hasNextPage: Boolean! 110 | 111 | """ 112 | When paginating backwards, are there more items? 113 | """ 114 | hasPreviousPage: Boolean! 115 | 116 | """ 117 | When paginating backwards, the cursor to continue. 118 | """ 119 | startCursor: String 120 | } 121 | 122 | type Query { 123 | items( 124 | """ 125 | Returns the items in the list that come after the specified cursor. 126 | """ 127 | after: String 128 | 129 | """ 130 | Returns the items in the list that come before the specified cursor. 131 | """ 132 | before: String 133 | 134 | """ 135 | Returns the first n items from the list. 136 | """ 137 | first: Int 138 | keyword: String 139 | 140 | """ 141 | Returns the last n items from the list. 142 | """ 143 | last: Int 144 | ): ItemConnection! 145 | 146 | """ 147 | Fetches an object given its ID 148 | """ 149 | node( 150 | """ 151 | The ID of an object 152 | """ 153 | id: ID! 154 | ): Node 155 | viewer: User 156 | } 157 | 158 | input RemoveItemInput { 159 | clientMutationId: String 160 | itemId: String! 161 | userId: ID! 162 | } 163 | 164 | type RemoveItemPayload { 165 | clientMutationId: String 166 | removedItem: RemovedItem! 167 | } 168 | 169 | type RemovedItem { 170 | """ 171 | The ID of an object 172 | """ 173 | id: ID! 174 | } 175 | 176 | type Subscription { 177 | itemAdded: ItemAddedPayload! 178 | itemRemoved: ItemRemovedPayload! 179 | } 180 | 181 | input UpdateItemInput { 182 | clientMutationId: String 183 | itemId: ID! 184 | newItemName: String! 185 | } 186 | 187 | type UpdateItemPayload { 188 | clientMutationId: String 189 | item: Item! 190 | } 191 | 192 | type User implements Node { 193 | """ 194 | The ID of an object 195 | """ 196 | id: ID! 197 | items( 198 | """ 199 | Returns the items in the list that come after the specified cursor. 200 | """ 201 | after: String 202 | 203 | """ 204 | Returns the items in the list that come before the specified cursor. 205 | """ 206 | before: String 207 | 208 | """ 209 | Returns the first n items from the list. 210 | """ 211 | first: Int 212 | keyword: String 213 | 214 | """ 215 | Returns the last n items from the list. 216 | """ 217 | last: Int 218 | ): ItemConnection! 219 | 220 | """ 221 | The name of the user. 222 | """ 223 | name: String 224 | } 225 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/src/utils/testing/utils.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@graphql-codegen/plugin-helpers'; 2 | import { readFileSync } from 'fs'; 3 | import { buildSchema, concatAST, parse, print } from 'graphql'; 4 | import path from 'path'; 5 | import { nonNullable } from '../nonNullable'; 6 | 7 | export const printDocuments = (documentFiles: Types.DocumentFile[]): string => { 8 | const ast = concatAST(documentFiles.map((v) => v.document).filter(nonNullable)); 9 | return print(ast); 10 | }; 11 | 12 | const filePath = path.join(__dirname, './example.graphql'); 13 | const schemaString = readFileSync(filePath, { encoding: 'utf-8' }); 14 | export const testGraphqlSchema = buildSchema(schemaString); 15 | export const testSchemaDocumentNode = parse(schemaString); 16 | -------------------------------------------------------------------------------- /packages/graphql-codegen-preset/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "emitDeclarationOnly": true 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__/", "**/*.test.ts", "**/testing/", "node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /setupJest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | if (typeof global.TextEncoder === 'undefined') { 3 | global.TextEncoder = require('util').TextEncoder; 4 | } 5 | 6 | if (typeof global.TextDecoder === 'undefined') { 7 | global.TextDecoder = require('util').TextDecoder; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "emitDeclarationOnly": true 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__/", "**/*.test.ts", "example", "**/testing/", "**/__test-utils__/**/*", "node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "declaration": true, 6 | "target": "es2019", 7 | "sourceMap": true, 8 | "baseUrl": ".", 9 | "outDir": "dist", 10 | "lib": ["es2019"], 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "skipLibCheck": true, 14 | "jsx": "react" 15 | } 16 | } 17 | --------------------------------------------------------------------------------