├── .eslintignore ├── jest.setup.js ├── babel.config.js ├── .prettierrc.js ├── jest.config.js ├── src ├── index.ts ├── utils.ts ├── types.ts ├── useMutation.ts ├── AST │ ├── LocalResolution │ │ ├── getTypeMapObj.ts │ │ └── resolveQueryWithLocalFields.ts │ ├── modifyFields.ts │ └── AST.ts ├── __tests__ │ └── useQuery.test.tsx ├── useQuery.ts └── atomiContext.tsx ├── .eslintrc.json ├── package.json ├── .gitignore ├── README.md ├── .github └── workflows │ └── github-actions.yml └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | ./src/AST.ts -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {presets: ['@babel/preset-env', '@babel/preset-react']} 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // setupFilesAfterEnv: ["./jest.setup.js"], 3 | roots: ["/src"], 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest" 6 | }, 7 | setupFilesAfterEnv: [ 8 | "@testing-library/jest-dom/extend-expect" 9 | ], 10 | 11 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 12 | // Module file extensions for importing 13 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"] 14 | }; 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request'; 2 | import { AtomiProvider, AtomiContext } from './atomiContext'; 3 | import useQuery from './useQuery'; 4 | import useMutation from './useMutation'; 5 | import { 6 | ResponseData, 7 | AtomData, 8 | AtomiAtom, 9 | AtomiAtomContainer, 10 | ReadQueryOutput, 11 | CacheContainer, 12 | Query, 13 | } from './types'; 14 | 15 | export { 16 | AtomiProvider, 17 | AtomiContext, 18 | useQuery, 19 | useMutation, 20 | ResponseData, 21 | AtomData, 22 | AtomiAtom, 23 | AtomiAtomContainer, 24 | ReadQueryOutput, 25 | CacheContainer, 26 | Query, 27 | gql, 28 | }; 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "airbnb/hooks", "prettier"], 3 | "plugins": [], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 2021, 7 | "sourceType": "module", 8 | "ecmaFeatures": { 9 | "jsx": true 10 | } 11 | }, 12 | "env": { 13 | "es6": true, 14 | "browser": true, 15 | "node": true, 16 | "jest": true 17 | }, 18 | "ignorePatterns": "/dist", 19 | "rules": { 20 | "react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }], 21 | "import/no-unresolved": 0, 22 | "import/extensions": 0, 23 | "no-restricted-syntax": 0, 24 | "no-param-reassign": 0, 25 | "no-use-before-define": 0, 26 | "consistent-return": 0, 27 | "no-underscore-dangle": 0, 28 | "no-unused-vars": 0 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atomiql", 3 | "version": "1.0.1", 4 | "description": "", 5 | "scripts": { 6 | "test": "jest -c jest.config.js --watch" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "main": "dist/src/index.js", 12 | "types": "dist/src/index.d.ts", 13 | "prepublish": "tsc", 14 | "devDependencies": { 15 | "@babel/preset-env": "^7.14.2", 16 | "@babel/preset-react": "^7.13.13", 17 | "@testing-library/jest-dom": "^5.12.0", 18 | "@testing-library/react": "^11.2.7", 19 | "@testing-library/react-hooks": "^6.0.0", 20 | "@types/jest": "^26.0.23", 21 | "@types/lodash": "^4.14.170", 22 | "@types/react": "^17.0.5", 23 | "@typescript-eslint/parser": "^4.28.2", 24 | "babel-eslint": "^10.1.0", 25 | "eslint": "^7.28.0", 26 | "eslint-config-airbnb": "^18.2.1", 27 | "eslint-config-prettier": "^8.3.0", 28 | "eslint-import-resolver-typescript": "^2.4.0", 29 | "eslint-plugin-import": "^2.23.2", 30 | "eslint-plugin-jsx-a11y": "^6.4.1", 31 | "eslint-plugin-react": "^7.23.2", 32 | "eslint-plugin-react-hooks": "^4.2.0", 33 | "jest": "^26.6.3", 34 | "prettier": "^2.3.1", 35 | "ts-jest": "^26.5.6", 36 | "typescript": "^4.3.5", 37 | "typescript-eslint": "^0.0.1-alpha.0" 38 | }, 39 | "peerDependencies": { 40 | "jotai": "*", 41 | "react": "*" 42 | }, 43 | "dependencies": { 44 | "graphql": "^15.5.0", 45 | "graphql-request": "^3.4.0", 46 | "graphql-tools": "^7.0.5", 47 | "lodash": "^4.17.21" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { PathObject, ServerState } from './types'; 2 | 3 | export const isObjectAndNotNull = (value: any) => 4 | typeof value === 'object' && !!value; 5 | 6 | export const objectKeysIncludes = (value: any, keyName: string) => 7 | isObjectAndNotNull(value) && Object.keys(value).includes(keyName); 8 | 9 | const resolveLocally = (pathValue: PathObject) => 10 | objectKeysIncludes(pathValue, 'resolveLocally'); 11 | 12 | export const mergeServerAndLocalState = ( 13 | serverState: ServerState, 14 | pathToResolvers: PathObject 15 | ) => { 16 | // If pathToResolvers is falsy hit the base case 17 | if (!pathToResolvers) return; 18 | // If serverState is an array, recursively call each element and return out 19 | if (Array.isArray(serverState)) { 20 | serverState.forEach((stateEl: ServerState) => 21 | mergeServerAndLocalState(stateEl, pathToResolvers) 22 | ); 23 | return; 24 | } 25 | // Otherwise iterate through each key value pair in the pathToResolvers object 26 | for (const [pathKey, pathValue] of Object.entries(pathToResolvers)) { 27 | // If we are resolving a whole SelectionSet locally, add the SelectionSet root node to the serverState 28 | if (serverState[pathKey] === undefined) serverState[pathKey] = {}; 29 | // If pathToResolvers says resolver locally, update the serverState with the local state 30 | if (resolveLocally(pathValue)) 31 | serverState[pathKey] = pathValue.resolveLocally; 32 | // Otherwise recursively call at the next level of depth in the server and path objects 33 | else mergeServerAndLocalState(serverState[pathKey], pathValue); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /.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 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # Nuxt.js build / generate output 76 | .nuxt 77 | dist 78 | 79 | # Stores VSCode versions used for testing VSCode extensions 80 | .vscode-test 81 | .vscode 82 | 83 | # yarn v2 84 | .yarn/cache 85 | .yarn/unplugged 86 | .yarn/build-state.yml 87 | .yarn/install-state.gz 88 | .pnp.* 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |

AtomiQL

5 | 6 |

7 | 8 |

9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 | ## Getting Started 18 | 19 | Starting to use AtomiQL is as easy as installing our package and Jotai in your application! 20 | ```sh 21 | $ npm install atomiql jotai 22 | ``` 23 | 24 | ## Documentation 25 | 26 | Visit our documentation to learn more about our application and try it out. 27 | 28 | 29 | ## Why use AtomiQL? 30 | 31 | AtomiQL is the first GraphQL client optimized and fully integrated with the Jotai atomic state management library. Our library allows your application to reap the benefits of traditional GraphQL clients along with the front-end performance optimizations of atomic state management libraries. 32 | 33 | See our website for a demonstration of how our application works! 34 | 35 | 36 | ## Contributing 37 | 38 | Feel free to message one of the contributors below if you're interested in contributing to AtomiQL 39 | 40 | - [Xiao Li](https://github.com/xiaotongli) 41 | - [Pat Liu](https://github.com/patrickliuhhs) 42 | - [Paulo Choi](https://github.com/paulochoi) 43 | - [Thomas Harper](https://github.com/tommyrharper) 44 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql'; 2 | import { GraphQLClient } from 'graphql-request'; 3 | import { Atom, PrimitiveAtom, WritableAtom } from 'jotai'; 4 | import { SetStateAction } from 'react'; 5 | 6 | export type ResponseData = { [key: string]: any }; 7 | 8 | export type Query = string | DocumentNode; 9 | 10 | export interface AtomData { 11 | loading: boolean; 12 | data: null | ResponseData; 13 | hasError: boolean; 14 | } 15 | 16 | export type AtomiAtom = PrimitiveAtom; 17 | 18 | export interface AtomiAtomContainer { 19 | originalQuery: string; 20 | variables: any; 21 | atom: AtomiAtom; 22 | atomData: AtomData; 23 | setAtom?: (update: SetStateAction) => void | Promise; 24 | } 25 | 26 | export interface ReadQueryOutput { 27 | writeAtom: (arg1: any) => void; 28 | data: any; 29 | } 30 | 31 | export interface Resolvers { 32 | [key: string]: Resolvers | ((...args: any[]) => void); 33 | } 34 | 35 | export interface PathObject { 36 | resolveLocally?: any; 37 | [key: string]: PathObject; 38 | } 39 | 40 | export interface CacheContainer { 41 | url: string; 42 | readQuery: (arg1: string) => ReadQueryOutput; 43 | atomCache: { 44 | [key: string]: AtomiAtomContainer; 45 | }; 46 | gqlNodeCache: { 47 | [key: string]: Object | null; 48 | }; 49 | queryAtomMap: { 50 | [key: string]: Set; 51 | }; 52 | setCache: (arg1: string, arg2: AtomiAtomContainer) => void; 53 | graphQLClient: GraphQLClient; 54 | resolvers: Resolvers; 55 | resolvePathToResolvers: ( 56 | pathToResolvers: PathObject, 57 | resolvers: Resolvers 58 | ) => void; 59 | getAtomiAtomContainer: (query: string) => AtomiAtomContainer; 60 | writeQuery: (query: string, newData: any) => void; 61 | typeDefs: DocumentNode; 62 | } 63 | 64 | export interface ServerState { 65 | [key: string]: ServerState; 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: AtomiQL Github Actions 2 | on: [push] 3 | jobs: 4 | Prettier-format: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | with: 9 | ref: ${{ github.head_ref }} 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '14' 13 | check-latest: true 14 | - name: Install packages 15 | run: npm i 16 | - name: Format 17 | run: npx prettier --write ./src/* 18 | - name: Commit changes 19 | uses: stefanzweifel/git-auto-commit-action@v4.1.2 20 | with: 21 | commit_message: Apply formatting changes 22 | branch: ${{ github.head_ref }} 23 | Run-tsc: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v2 28 | with: 29 | node-version: '14' 30 | check-latest: true 31 | - name: Install packages 32 | run: npm i 33 | - name: Install peer dependencies 34 | run: | 35 | npm install react 36 | npm install jotai 37 | - name: Run tsc 38 | run: tsc 39 | - run: echo "🍏 This job's status is ${{ job.status }}." 40 | Run-eslint: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-node@v2 45 | with: 46 | node-version: '14' 47 | check-latest: true 48 | - name: Install packages 49 | run: npm i 50 | - name: Run eslint 51 | run: ./node_modules/.bin/eslint ./src/* --ext .js,.jsx,.ts,.tsx 52 | - run: echo "🍏 This job's status is ${{ job.status }}." 53 | Run-tests: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v2 57 | - uses: actions/setup-node@v2 58 | with: 59 | node-version: '14' 60 | check-latest: true 61 | - name: Install packages 62 | run: npm i 63 | - run: echo "::error ::Tests not functional at this time. to update upon completion of tests" 64 | -------------------------------------------------------------------------------- /src/useMutation.ts: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import { AtomData, CacheContainer, Query } from './types'; 3 | import { AtomiContext } from './atomiContext'; 4 | import { parseQuery } from './AST/AST'; 5 | 6 | const initialData: AtomData = { 7 | loading: false, 8 | data: null, 9 | hasError: false, 10 | }; 11 | interface MutationArg { 12 | [key: string]: any; 13 | } 14 | 15 | type MutationCallback = (arg1: CacheContainer, arg2: AtomData) => void; 16 | 17 | const useMutation = ( 18 | query: Query, 19 | callback?: MutationCallback 20 | ): [(arg1: MutationArg) => void, AtomData] => { 21 | // Parse the users Query to convert it into reliable format 22 | const { queryString } = parseQuery(query); 23 | // Access the cache 24 | const cacheContainer = useContext(AtomiContext); 25 | const { graphQLClient } = cacheContainer; 26 | const [response, setResponse] = useState(initialData); 27 | 28 | // Define the function the user can use to execute their mutation 29 | const triggerMutation = async (mutationArg: MutationArg) => { 30 | // Set loading to true in the response data 31 | setResponse({ 32 | ...response, 33 | loading: true, 34 | }); 35 | try { 36 | // Send Mutation to the server 37 | const result = await graphQLClient.request(queryString, mutationArg); 38 | const newResponse: AtomData = { 39 | data: result, 40 | loading: false, 41 | hasError: false, 42 | }; 43 | // Update the response with the server result 44 | setResponse(newResponse); 45 | // If the user passed in a callback into useMutation execute it now the request is complete 46 | if (callback) callback(cacheContainer, newResponse); 47 | } catch { 48 | // If there is an error set loading to false and hasError to true 49 | setResponse({ 50 | data: null, 51 | loading: false, 52 | hasError: true, 53 | }); 54 | } 55 | }; 56 | 57 | // Send to the user the triggerMutation function and response data 58 | return [triggerMutation, response]; 59 | }; 60 | 61 | export default useMutation; 62 | -------------------------------------------------------------------------------- /src/AST/LocalResolution/getTypeMapObj.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | /* eslint-disable import/prefer-default-export */ 3 | /* eslint-disable no-underscore-dangle */ 4 | 5 | interface TypeDefinition { 6 | value: string; 7 | astNode: { 8 | kind: any; 9 | }; 10 | _nameLookup: any; 11 | name: any; 12 | _fields: { 13 | [key: string]: { 14 | type: any; 15 | }; 16 | }; 17 | } 18 | 19 | export type GraphQLSchemaFull = GraphQLSchema & { 20 | _typeMap: { 21 | [key: string]: TypeDefinition; 22 | }; 23 | _queryType: { 24 | _fields: any; 25 | }; 26 | type: any; 27 | name: any; 28 | kind: any; 29 | }; 30 | 31 | export interface TypeMapObj { 32 | [key: string]: any; 33 | } 34 | 35 | const getTypeDefinitionObj = ( 36 | type: string, 37 | executableSchema: GraphQLSchemaFull 38 | ) => { 39 | const output = { 40 | [type]: {}, 41 | }; 42 | const outputType: TypeMapObj = output[type]; 43 | 44 | const typeDefinition = executableSchema._typeMap[type]; 45 | // Handle Enums 46 | if (typeDefinition.astNode) { 47 | if (typeDefinition.astNode.kind === 'EnumTypeDefinition') { 48 | output[type] = Object.keys(typeDefinition._nameLookup); 49 | return output; 50 | } 51 | } 52 | // Handle inbuilt types 53 | if (!typeDefinition._fields) { 54 | output[type] = typeDefinition.name; 55 | return output; 56 | } 57 | // Handle user types 58 | for (const [key, value] of Object.entries(typeDefinition._fields)) { 59 | outputType[key] = value.type; 60 | } 61 | return output; 62 | }; 63 | 64 | interface IgnoreType { 65 | [key: string]: boolean | undefined; 66 | } 67 | 68 | const IGNORE_TYPE: IgnoreType = { 69 | Boolean: true, 70 | ID: true, 71 | Int: true, 72 | String: true, 73 | __Directive: true, 74 | __DirectiveLocation: true, 75 | __EnumValue: true, 76 | __Field: true, 77 | __InputValue: true, 78 | __Schema: true, 79 | __Type: true, 80 | __TypeKind: true, 81 | }; 82 | 83 | const ignoreType = (type: string) => !!IGNORE_TYPE[type]; 84 | 85 | export const getTypeMapObj = ( 86 | executableSchema: GraphQLSchemaFull 87 | ): TypeMapObj => { 88 | const output: TypeMapObj = {}; 89 | const types = Object.keys(executableSchema._typeMap); 90 | types.forEach((type) => { 91 | if (!ignoreType(type)) 92 | output[type] = getTypeDefinitionObj(type, executableSchema)[type]; 93 | }); 94 | return output; 95 | }; 96 | -------------------------------------------------------------------------------- /src/__tests__/useQuery.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { render, screen, cleanup } from '@testing-library/react'; 3 | import { renderHook } from '@testing-library/react-hooks'; 4 | import { gql } from 'graphql-request'; 5 | import useQuery, { GetAtom } from '../useQuery'; 6 | import { AtomiProvider, AtomiContext } from '../atomiContext'; 7 | 8 | const client = { 9 | url: 'https://graphql-pokemon2.vercel.app', 10 | }; 11 | 12 | describe('AtomiContext', () => { 13 | afterEach(() => { 14 | cleanup(); 15 | }); 16 | 17 | test('Component under AtomiProvider should render', () => { 18 | render( 19 | 20 |
Test Render
21 |
22 | ); 23 | expect(screen.getByText('Test Render')).toBeInTheDocument(); 24 | }); 25 | 26 | test('URL should be available to context API store via the useContext hook', async () => { 27 | function CheckContext() { 28 | const result = useContext(AtomiContext); 29 | return
{result.url}
; 30 | } 31 | const url = 'https://graphql-pokemon2.vercel.app'; 32 | 33 | render( 34 | 35 | 36 | 37 | ); 38 | 39 | expect(screen.getByText(url)).toBeInTheDocument(); 40 | }); 41 | }); 42 | 43 | describe('UseQuery', () => { 44 | afterEach(() => { 45 | cleanup(); 46 | }); 47 | 48 | test('Data should load from useQuery hook', async () => { 49 | const query = gql` 50 | query { 51 | pokemons(first: 3) { 52 | id 53 | name 54 | } 55 | } 56 | `; 57 | const wrapper: React.ComponentType = ({ children }): React.ReactElement => ( 58 | {children} 59 | ); 60 | 61 | const { result, waitForNextUpdate } = renderHook(() => useQuery(query), { 62 | wrapper, 63 | }); 64 | await waitForNextUpdate(); 65 | 66 | expect(result?.current[0]?.pokemons.length).toBe(3); 67 | }); 68 | 69 | test('Data should be available in the Jotai atom', async () => { 70 | const query = gql` 71 | query { 72 | pokemons(first: 3) { 73 | id 74 | name 75 | } 76 | } 77 | `; 78 | const wrapper: React.ComponentType = ({ children }): React.ReactElement => ( 79 | {children} 80 | ); 81 | 82 | const { waitForNextUpdate } = renderHook(() => useQuery(query), { 83 | wrapper, 84 | }); 85 | await waitForNextUpdate(); 86 | 87 | const { result } = renderHook(() => GetAtom(), { wrapper }); 88 | expect(result?.current?.data?.pokemons.length).toBe(3); 89 | }); 90 | 91 | test('Ensure query is being stored on the cache', async () => { 92 | expect(1).toBe(0); 93 | }); 94 | 95 | test('If query is ran more than once, it should be retrieved from cache', async () => { 96 | expect(1).toBe(0); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/AST/modifyFields.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable no-unused-vars */ 3 | /* eslint-disable import/prefer-default-export */ 4 | 5 | import { 6 | DocumentNode, 7 | FieldNode, 8 | SelectionNode, 9 | SelectionSetNode, 10 | visit, 11 | } from 'graphql'; 12 | 13 | export function addFields( 14 | query: DocumentNode, 15 | fieldNames: ReadonlyArray 16 | ): DocumentNode { 17 | // When using visit() to edit an AST, the original AST will not be modified, 18 | // and a new version of the AST with the changes applied will be returned from the visit function. 19 | return visit(query, { 20 | SelectionSet: { 21 | leave( 22 | node: SelectionSetNode 23 | // _key: string, 24 | // _parent: ASTNode, 25 | // _path: ReadonlyArray 26 | ): {} | undefined { 27 | // @return 28 | // undefined: no action 29 | // false: no action 30 | // visitor.BREAK: stop visiting altogether 31 | // null: delete this node 32 | // any value: replace this node with the returned value 33 | 34 | // // Don't add to the top-level selection-set 35 | // if (parent && parent.kind === Kind.OPERATION_DEFINITION) { 36 | // return undefined; 37 | // } 38 | 39 | const fieldsToAdd: Array = []; 40 | for (const fieldName of fieldNames) { 41 | if (!hasField(fieldName)(node.selections)) { 42 | const fieldToAdd = createField(fieldName); 43 | fieldsToAdd.push(fieldToAdd); 44 | } 45 | } 46 | if (fieldsToAdd.length > 0) { 47 | const newNode = { 48 | ...node, 49 | selections: [...node.selections, ...fieldsToAdd], 50 | }; 51 | return newNode; 52 | } 53 | return false; 54 | }, 55 | }, 56 | }); 57 | } 58 | 59 | function hasField( 60 | name: string 61 | ): (selectionSet: ReadonlyArray) => boolean { 62 | type Some = ( 63 | value: SelectionNode, 64 | index: number, 65 | array: SelectionNode[] 66 | ) => unknown; 67 | const some = ({ name: { value } }: FieldNode) => value === name; 68 | const someFunc = some as Some; 69 | 70 | return (selectionSet) => 71 | selectionSet.filter((s) => s.kind === 'Field').some(someFunc); 72 | } 73 | 74 | function createField(name: string): FieldNode { 75 | return { 76 | kind: 'Field', 77 | alias: undefined, 78 | name: { 79 | kind: 'Name', 80 | value: name, 81 | }, 82 | // tslint:disable-next-line:no-arguments 83 | arguments: [], 84 | directives: [], 85 | selectionSet: undefined, 86 | }; 87 | } 88 | 89 | export function removeFields( 90 | query: DocumentNode, 91 | fieldsToRemove: ReadonlyArray 92 | ): DocumentNode { 93 | return visit(query, { 94 | // tslint:disable-next-line:function-name 95 | Field: { 96 | enter(node: any) { 97 | return fieldsToRemove.indexOf(node.name.value) > -1 ? null : undefined; 98 | }, 99 | }, 100 | }); 101 | } 102 | -------------------------------------------------------------------------------- /src/AST/LocalResolution/resolveQueryWithLocalFields.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | import { graphql, DocumentNode } from 'graphql'; 5 | import { PathObject, Resolvers } from '../../types'; 6 | import { GraphQLSchemaFull, TypeMapObj } from './getTypeMapObj'; 7 | 8 | const { getTypeMapObj } = require('./getTypeMapObj'); 9 | 10 | const getQueryField = ( 11 | executableSchema: GraphQLSchemaFull, 12 | queryName: string 13 | ): GraphQLSchemaFull => executableSchema._queryType._fields[queryName]; 14 | 15 | const isNamedTypeNode = (node: { kind: string }) => node.kind === 'NamedType'; 16 | const typeNodeHasType = (node: { type: any }) => !!node.type; 17 | 18 | interface QueryResponseTypeCustomObj { 19 | isResponseDefinition: boolean; 20 | name?: any; 21 | [key: string]: any; 22 | [key: number]: any; 23 | } 24 | 25 | const getQueryResponseTypeCustomObj = ( 26 | executableSchema: GraphQLSchemaFull, 27 | queryName: string 28 | ) => { 29 | const queryField = getQueryField(executableSchema, queryName); 30 | const queryResponseType = queryField.astNode as unknown as GraphQLSchemaFull; 31 | const output: QueryResponseTypeCustomObj = { isResponseDefinition: true }; 32 | const recurseType = (node: GraphQLSchemaFull) => { 33 | if (isNamedTypeNode(node)) { 34 | output.name = node.name.value; 35 | } 36 | if (typeNodeHasType(node)) { 37 | output[node.kind] = true; 38 | recurseType(node.type); 39 | } 40 | }; 41 | recurseType(queryResponseType.type); 42 | return output; 43 | }; 44 | 45 | const generateQueryResolver = ( 46 | resolvers: Resolvers, 47 | executableSchema: GraphQLSchemaFull, 48 | queries: string[], 49 | serverResponse: any 50 | ) => { 51 | const newResolver = { 52 | Query: {}, 53 | }; 54 | queries.forEach((query) => { 55 | const queryResponseTypeCustomObj = getQueryResponseTypeCustomObj( 56 | executableSchema, 57 | query 58 | ); 59 | newResolver.Query = { 60 | ...newResolver.Query, 61 | [query]() { 62 | if (serverResponse) return serverResponse[query]; 63 | if (queryResponseTypeCustomObj.ListType) return [{}]; 64 | return {}; 65 | }, 66 | }; 67 | }); 68 | if (resolvers.Query) { 69 | newResolver.Query = { 70 | // Switch these around to give priority to remote query resolvers 71 | ...newResolver.Query, 72 | ...resolvers.Query, 73 | }; 74 | } 75 | return newResolver; 76 | }; 77 | 78 | interface TypeDefNode { 79 | [key: string]: ResponseType; 80 | } 81 | 82 | const generateFieldResolvers = ( 83 | resolvers: Resolvers, 84 | pathToResolvers: PathObject, 85 | typeMapObj: TypeMapObj 86 | ) => { 87 | const newResolver: Resolvers = {}; 88 | const recurse = ( 89 | pathToNode: PathObject, 90 | typeDefNode: TypeDefNode, 91 | typeName: string 92 | ) => { 93 | for (const [key, val] of Object.entries(pathToNode)) { 94 | if (val.resolveLocally) { 95 | const resolver = resolvers[typeName] as Resolvers; 96 | newResolver[typeName] = { 97 | ...newResolver[typeName], 98 | [key]: resolver[key], 99 | }; 100 | } else { 101 | const newTypeName = getTypeName(typeDefNode[key]); 102 | recurse(val, typeMapObj[newTypeName], newTypeName); 103 | } 104 | } 105 | }; 106 | for (const [key, value] of Object.entries(pathToResolvers)) { 107 | const typeName = getTypeName(typeMapObj.Query[key]); 108 | const typeDef = typeMapObj[typeName]; 109 | if (!value.resolveLocally) { 110 | recurse(value, typeDef, typeName); 111 | } 112 | } 113 | return newResolver; 114 | }; 115 | 116 | const generateOneOffResolver = ( 117 | resolvers: Resolvers, 118 | pathToResolvers: PathObject, 119 | executableSchema: GraphQLSchemaFull, 120 | serverResponse: any 121 | ) => { 122 | const typeMapObj = getTypeMapObj(executableSchema); 123 | const queries = Object.keys(pathToResolvers); 124 | const queryResolver = generateQueryResolver( 125 | resolvers, 126 | executableSchema, 127 | queries, 128 | serverResponse 129 | ); 130 | const fieldResolvers = generateFieldResolvers( 131 | resolvers, 132 | pathToResolvers, 133 | typeMapObj 134 | ); 135 | return { ...queryResolver, ...fieldResolvers }; 136 | }; 137 | 138 | interface ResponseType { 139 | ofType: ResponseType; 140 | name: string; 141 | } 142 | 143 | const getTypeName = (responseType: ResponseType): string => { 144 | if (responseType.name) return responseType.name; 145 | return getTypeName(responseType.ofType); 146 | }; 147 | 148 | const createLocalExecutableSchema = ( 149 | typeDefs: DocumentNode, 150 | resolvers: Resolvers, 151 | pathToResolvers: PathObject, 152 | executableSchema: GraphQLSchemaFull, 153 | serverResponse: any 154 | ) => { 155 | const generatedResolver = generateOneOffResolver( 156 | resolvers, 157 | pathToResolvers, 158 | executableSchema, 159 | serverResponse 160 | ); 161 | return makeExecutableSchema({ 162 | typeDefs, 163 | resolvers: generatedResolver, 164 | }); 165 | }; 166 | 167 | export const resolveQueryWithLocalFields = async ( 168 | typeDefs: DocumentNode, 169 | resolvers: Resolvers, 170 | pathToResolvers: PathObject, 171 | serverResponse: any, 172 | query: string 173 | ) => { 174 | const executableSchema = makeExecutableSchema({ 175 | typeDefs, 176 | resolvers, 177 | }) as GraphQLSchemaFull; 178 | 179 | const newExecutableSchema = createLocalExecutableSchema( 180 | typeDefs, 181 | resolvers, 182 | pathToResolvers, 183 | executableSchema, 184 | serverResponse 185 | ); 186 | 187 | return (await graphql(newExecutableSchema, query)).data; 188 | }; 189 | -------------------------------------------------------------------------------- /src/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from 'jotai'; 2 | import { useEffect, useContext } from 'react'; 3 | import { GraphQLClient } from 'graphql-request'; 4 | import { DocumentNode } from 'graphql'; 5 | import { AtomiContext } from './atomiContext'; 6 | import { 7 | AtomData, 8 | AtomiAtom, 9 | Query, 10 | ResponseData, 11 | PathObject, 12 | Resolvers, 13 | } from './types'; 14 | import { parseQuery } from './AST/AST'; 15 | import { resolveQueryWithLocalFields } from './AST/LocalResolution/resolveQueryWithLocalFields'; 16 | 17 | type AtomDataArray = [null | ResponseData, boolean, boolean]; 18 | 19 | const initialAtomData: AtomData = { 20 | loading: true, 21 | data: null, 22 | hasError: false, 23 | }; 24 | 25 | interface UseQueryInput { 26 | variables?: any; 27 | isLocal?: boolean; 28 | } 29 | 30 | type Result = { [key: string]: any }; 31 | 32 | export const getQueryResult = async ( 33 | sendQueryToServer: boolean, 34 | graphQLClient: GraphQLClient, 35 | updatedAST: DocumentNode, 36 | variables: any, 37 | foundClientDirective: boolean, 38 | typeDefs: DocumentNode, 39 | resolvers: Resolvers, 40 | pathToResolvers: PathObject, 41 | strippedQuery: string 42 | ) => { 43 | let result: Result = {}; 44 | // Query the server if Query is valid 45 | if (sendQueryToServer) { 46 | result = await graphQLClient.request(updatedAST, variables); 47 | } 48 | // If there are @client directives in the query, merge the result from 49 | // the server with local state from the resolvers for those Fields 50 | if (foundClientDirective) { 51 | // resolvePathToResolvers(pathToResolvers, resolvers); 52 | // mergeServerAndLocalState(result, pathToResolvers); 53 | result = (await resolveQueryWithLocalFields( 54 | typeDefs, 55 | resolvers, 56 | pathToResolvers, 57 | result, 58 | strippedQuery 59 | )) as Result; 60 | } 61 | return result; 62 | }; 63 | 64 | const useQuery = (query: Query, input?: UseQueryInput): AtomDataArray => { 65 | const isLocal = input && input.isLocal; 66 | // Parse the graphQL query 67 | const { 68 | updatedAST, 69 | strippedQuery, 70 | queryString: originalQuery, 71 | pathToResolvers, 72 | foundClientDirective, 73 | sendQueryToServer, 74 | } = parseQuery(query); 75 | let queryString = originalQuery; 76 | if (input && input.variables) queryString += JSON.stringify(input.variables); 77 | // Access the cache 78 | const { 79 | setCache, 80 | graphQLClient, 81 | resolvers, 82 | getAtomiAtomContainer, 83 | typeDefs, 84 | } = useContext(AtomiContext); 85 | // Look for a cachedAtomContainer 86 | const cachedAtomContainer = getAtomiAtomContainer(queryString); 87 | const cachedAtom = cachedAtomContainer ? cachedAtomContainer.atom : null; 88 | // If there is no cached atom, set the active atom to be a new atom 89 | const activeAtom: AtomiAtom = cachedAtom || atom(initialAtomData); 90 | // Hooke into the activeAtom 91 | const [atomData, setAtom] = useAtom(activeAtom); 92 | 93 | const variables = input ? input.variables : undefined; 94 | 95 | const setCacheContents = { 96 | originalQuery, 97 | variables, 98 | atom: activeAtom, 99 | setAtom, 100 | }; 101 | 102 | useEffect(() => { 103 | (async () => { 104 | // If the atom is cached or it is a local only query do not query the server 105 | if (!cachedAtom && !isLocal) { 106 | const newAtomData: AtomData = { 107 | data: null, 108 | loading: false, 109 | hasError: false, 110 | }; 111 | try { 112 | const result = await getQueryResult( 113 | sendQueryToServer, 114 | graphQLClient, 115 | updatedAST, 116 | variables, 117 | foundClientDirective, 118 | typeDefs, 119 | resolvers, 120 | pathToResolvers, 121 | strippedQuery 122 | ); 123 | newAtomData.data = result; 124 | // Set the response in the cache 125 | setCache(queryString, { 126 | ...setCacheContents, 127 | atomData: newAtomData, 128 | }); 129 | // Update the value of the Jotai atom 130 | setAtom(newAtomData); 131 | } catch { 132 | // Catch any errors 133 | newAtomData.hasError = true; 134 | // Set the cache 135 | setCache(queryString, { 136 | ...setCacheContents, 137 | atomData: newAtomData, 138 | }); 139 | // Update the value of the Jotai atom 140 | setAtom(newAtomData); 141 | } 142 | } 143 | // If atom is cached but setAtom function is not defined 144 | if (cachedAtomContainer && !cachedAtomContainer.setAtom) { 145 | // Save the setAtom function so it becomes accessible 146 | setCache(queryString, { 147 | ...setCacheContents, 148 | atomData, 149 | }); 150 | } 151 | // If the query is Local and there is no cache hit 152 | if (isLocal && !cachedAtom) { 153 | // Set the cache with data null so that writeQuery 154 | // will update this atom and effect state changes 155 | const newAtomData: AtomData = { 156 | data: null, 157 | loading: true, 158 | hasError: false, 159 | }; 160 | setCache(queryString, { 161 | ...setCacheContents, 162 | atomData: newAtomData, 163 | }); 164 | } 165 | })(); 166 | /* eslint react-hooks/exhaustive-deps:0 */ 167 | }, []); 168 | 169 | // If the atom is empty, assume the values of loading and hasError 170 | if (isAtomEmpty(atomData)) return [null, true, false]; 171 | 172 | // Return to the user data about and response from their request 173 | return [atomData.data, atomData.loading, atomData.hasError]; 174 | }; 175 | 176 | const isAtomEmpty = (atomData: any) => 177 | typeof atomData.loading === 'undefined' && 178 | typeof atomData.loading === 'undefined'; 179 | 180 | export const GetAtom = (): AtomData => { 181 | const [atomData] = useAtom(atom(initialAtomData)); 182 | return atomData; 183 | }; 184 | 185 | export default useQuery; 186 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ESNEXT", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist/src", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/AST/AST.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | parse, 4 | visit, 5 | FieldNode, 6 | DirectiveNode, 7 | print, 8 | } from 'graphql'; 9 | import { PathObject, Query, ResponseData } from '../types'; 10 | import { addFields } from './modifyFields'; 11 | 12 | export type Directives = readonly DirectiveNode[]; 13 | 14 | export interface UpdatedASTResponse { 15 | updatedAST: DocumentNode; 16 | pathToResolvers: PathObject; 17 | foundClientDirective: boolean; 18 | sendQueryToServer: boolean; 19 | } 20 | export interface ParseQueryResponse { 21 | updatedAST: DocumentNode; 22 | queryString: string; 23 | pathToResolvers: PathObject; 24 | foundClientDirective: boolean; 25 | sendQueryToServer: boolean; 26 | strippedQuery: string; 27 | } 28 | 29 | export const getASTFromQuery = (query: Query): DocumentNode => 30 | typeof query === 'string' ? parse(query) : query; 31 | 32 | const nodeHasDirectives = (node: FieldNode): boolean => 33 | !!node.directives && node.directives.length > 0; 34 | 35 | const directiveIsType = (type: string, directives?: Directives) => 36 | !!directives && directives[0].name.value === type; 37 | 38 | const nodeHasClientDirective = (node: FieldNode) => 39 | nodeHasDirectives(node) && directiveIsType('client', node.directives); 40 | 41 | const updatePathToResolversOnEnter = ( 42 | pathToResolvers: PathObject, 43 | node: FieldNode 44 | ) => { 45 | const name: string = node.name.value; 46 | // Add a key of each Field name to pathToResolvers 47 | pathToResolvers[name] = {}; 48 | // Add a link from each child Field its parent 49 | pathToResolvers[name].parent = pathToResolvers; 50 | // Return the pathToResolvers at the next level of depth 51 | return pathToResolvers[name]; 52 | }; 53 | 54 | const updatePathToResolversOnLeave = ( 55 | pathToResolvers: PathObject, 56 | node: FieldNode 57 | ) => { 58 | // Move pathResolver one level up towards its root 59 | pathToResolvers = pathToResolvers.parent; 60 | const name: string = node.name.value; 61 | // If this Field has an @client directive tell it to resolveLocally 62 | if (nodeHasClientDirective(node)) pathToResolvers[name].resolveLocally = true; 63 | return pathToResolvers; 64 | }; 65 | 66 | export const removeFieldsWithClientDirectiveAndCreatePathToResolvers = ( 67 | AST: DocumentNode 68 | ): UpdatedASTResponse => { 69 | let foundClientDirective = false; 70 | let pathToResolvers: PathObject = {}; 71 | const selectionSetLengths: { end?: number; start?: number }[] = []; 72 | let i = 0; 73 | const updatedAST = visit(AST, { 74 | Field: { 75 | enter(node: FieldNode) { 76 | // Track in pathToResolvers each Field in the query and move it one level deeper 77 | pathToResolvers = updatePathToResolversOnEnter(pathToResolvers, node); 78 | 79 | const { selectionSet } = node; 80 | 81 | if (selectionSet) { 82 | // Save the number of child Fields this Field has 83 | // in the format { start: number-of-child-fields } 84 | selectionSetLengths.push({ start: selectionSet.selections.length }); 85 | i += 1; 86 | } 87 | }, 88 | leave(node: FieldNode) { 89 | // Update and move pathResolver back up one level towards its root 90 | pathToResolvers = updatePathToResolversOnLeave(pathToResolvers, node); 91 | 92 | // If this Field has an @client directive remove it from the AST 93 | if (nodeHasClientDirective(node)) { 94 | foundClientDirective = true; 95 | // Returning null removes this field from the AST 96 | return null; 97 | } 98 | 99 | const { selectionSet } = node; 100 | 101 | // Save the number of child Fields this Field now has after editing the AST 102 | // in the format { end: number-of-child-fields } 103 | if (selectionSet) { 104 | i -= 1; 105 | const selection = selectionSetLengths[i]; 106 | selection.end = selectionSet.selections.length; 107 | 108 | // If at the start this Field had child Fields, and now it has None 109 | // Remove this Field from the Query so the Query remains valid 110 | if (selection.start && !selection.end) return null; 111 | } 112 | }, 113 | }, 114 | }); 115 | // If @client directive found remove the links from each node to its parent in pathToResolvers 116 | if (foundClientDirective) removeParentFieldsFromTree(pathToResolvers); 117 | 118 | let sendQueryToServer = true; 119 | const rootSelectionSet = selectionSetLengths[0]; 120 | 121 | // If the root Field has no child Fields, do not send the request to the server 122 | if (!!rootSelectionSet && rootSelectionSet.start && !rootSelectionSet.end) { 123 | sendQueryToServer = false; 124 | } 125 | 126 | return { 127 | updatedAST, 128 | pathToResolvers, 129 | foundClientDirective, 130 | sendQueryToServer, 131 | }; 132 | }; 133 | 134 | // removeParentFieldsFromTree removes all key -> child pairs with the key name 'parent' from a tree 135 | export const removeParentFieldsFromTree = (pathToResolvers: PathObject) => { 136 | for (const [key, value] of Object.entries(pathToResolvers)) { 137 | if (key === 'parent') delete pathToResolvers[key]; 138 | else removeParentFieldsFromTree(value); 139 | } 140 | // This is an optimization that removes any empty fields from the tree. In this case 141 | // that is each Field on the Query that does not have an @client directive. This 142 | // improves efficiency as further tree traversals don't have to check these nodes. 143 | removeEmptyFields(pathToResolvers); 144 | }; 145 | 146 | // Remove every value of {} in a tree 147 | export const removeEmptyFields = (pathToResolvers: PathObject) => { 148 | for (const [key, value] of Object.entries(pathToResolvers)) { 149 | if (JSON.stringify(value) === '{}') delete pathToResolvers[key]; 150 | else removeEmptyFields(value); 151 | } 152 | }; 153 | 154 | export const stripClientDirectivesFromQuery = (query: Query): string => { 155 | const queryAST = getASTFromQuery(query); 156 | 157 | const strippedQuery = visit(queryAST, { 158 | Directive: { 159 | leave(node) { 160 | if (node.name.value === 'client') return null; 161 | }, 162 | }, 163 | }); 164 | return print(strippedQuery); 165 | }; 166 | 167 | export const parseQuery = (query: Query): ParseQueryResponse => { 168 | // Get the AST from the Query 169 | const AST = addFields(getASTFromQuery(query), ['__typename']); 170 | const strippedQuery = stripClientDirectivesFromQuery(AST); 171 | // The updated AST has had all fields with @client directives removed 172 | // pathToResolvers is an object that describes the path to the resolvers for any @client directives 173 | const { 174 | updatedAST, 175 | pathToResolvers, 176 | foundClientDirective, 177 | sendQueryToServer, 178 | } = removeFieldsWithClientDirectiveAndCreatePathToResolvers(AST); 179 | return { 180 | updatedAST, 181 | pathToResolvers, 182 | queryString: print(AST), 183 | foundClientDirective, 184 | sendQueryToServer, 185 | strippedQuery, 186 | }; 187 | }; 188 | 189 | export const flattenQuery = (atomData: ResponseData) => { 190 | const output: ResponseData = {}; 191 | 192 | const flattenRecursive = (queryResult: any) => { 193 | if (Array.isArray(queryResult)) { 194 | queryResult.forEach((result) => { 195 | flattenRecursive(result); 196 | }); 197 | } else { 198 | if (queryResult.__typename && queryResult.id) { 199 | const uniqueId: string = `${queryResult.__typename}-${queryResult.id}`; 200 | output[uniqueId] = queryResult; 201 | } 202 | Object.keys(queryResult).forEach((queryKey) => { 203 | if (typeof queryResult[queryKey] === 'object') { 204 | flattenRecursive(queryResult[queryKey]); 205 | } 206 | }); 207 | } 208 | }; 209 | 210 | flattenRecursive(atomData); 211 | 212 | return output; 213 | }; 214 | -------------------------------------------------------------------------------- /src/atomiContext.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql'; 2 | import { GraphQLClient } from 'graphql-request'; 3 | import { isEqual } from 'lodash'; 4 | import { atom } from 'jotai'; 5 | import React from 'react'; 6 | import { flattenQuery, getASTFromQuery, parseQuery } from './AST/AST'; 7 | 8 | import { 9 | AtomData, 10 | AtomiAtom, 11 | AtomiAtomContainer, 12 | CacheContainer, 13 | PathObject, 14 | ReadQueryOutput, 15 | Resolvers, 16 | ResponseData, 17 | } from './types'; 18 | import { getQueryResult } from './useQuery'; 19 | 20 | interface Client { 21 | url: string; 22 | resolvers?: Resolvers; 23 | typeDefs?: DocumentNode | string; 24 | } 25 | interface AtomiProviderProps { 26 | client: Client; 27 | } 28 | 29 | const MOCK_TYPE_DEF = 'type Default { name: String }'; 30 | 31 | const initialCache: CacheContainer = { 32 | url: '', 33 | // eslint-disable-next-line no-unused-vars 34 | readQuery: (arg1: string) => ({ data: {}, writeAtom: () => ({}) }), 35 | // eslint-disable-next-line no-unused-vars 36 | setCache: (arg1: string, arg2: AtomiAtomContainer) => ({}), 37 | atomCache: {}, 38 | gqlNodeCache: {}, 39 | queryAtomMap: {}, 40 | graphQLClient: new GraphQLClient(''), 41 | resolvers: {}, 42 | resolvePathToResolvers: () => ({}), 43 | getAtomiAtomContainer: () => ({ 44 | atom: atom({}) as unknown as AtomiAtom, 45 | atomData: { 46 | loading: false, 47 | hasError: false, 48 | data: {}, 49 | }, 50 | setAtom: undefined, 51 | originalQuery: '', 52 | variables: {}, 53 | }), 54 | writeQuery: () => ({}), 55 | typeDefs: getASTFromQuery(MOCK_TYPE_DEF), 56 | }; 57 | 58 | export const AtomiContext = React.createContext(initialCache); 59 | 60 | export class AtomiProvider extends React.Component { 61 | cacheContainer: CacheContainer; 62 | 63 | constructor(props: AtomiProviderProps) { 64 | super(props); 65 | const { 66 | client: { url, resolvers, typeDefs }, 67 | } = this.props; 68 | const graphQLClient = new GraphQLClient(url); 69 | const cacheContainer: CacheContainer = { 70 | url, 71 | setCache: this.setCache, 72 | readQuery: this.readQuery, 73 | atomCache: {}, 74 | gqlNodeCache: {}, 75 | queryAtomMap: {}, 76 | graphQLClient, 77 | resolvers: resolvers || {}, 78 | resolvePathToResolvers: this.resolvePathToResolvers, 79 | getAtomiAtomContainer: this.getAtomiAtomContainer, 80 | writeQuery: this.writeQuery, 81 | typeDefs: getASTFromQuery(typeDefs || MOCK_TYPE_DEF), 82 | }; 83 | this.cacheContainer = cacheContainer; 84 | } 85 | 86 | // Update the pathToResolvers object with the resolved local state 87 | resolvePathToResolvers = ( 88 | pathToResolvers: PathObject, 89 | resolvers: Resolvers 90 | ) => { 91 | // Iterate through each key-value pair in the pathToResolvers object 92 | for (const [pathKey, pathValue] of Object.entries(pathToResolvers)) { 93 | // Get the the resolverNode associated with this pathKey 94 | const nextResolverNode = resolvers[pathKey]; 95 | // If the pathValue says to resolve locally, update the resolveLocally object with the resolved local value 96 | if (pathValue.resolveLocally && typeof nextResolverNode === 'function') 97 | pathValue.resolveLocally = nextResolverNode(); 98 | // Otherwise continue recursively searching the tree for Fields to resolve locally 99 | else if (typeof nextResolverNode === 'object') 100 | this.resolvePathToResolvers(pathValue, nextResolverNode); 101 | } 102 | }; 103 | 104 | // Store in the cache an atom container associated with a certain query 105 | setCache = (query: string, atomiAtomContainer: AtomiAtomContainer) => { 106 | // if the query does not return any data, then update the atomcache but not anything else 107 | if (!atomiAtomContainer.atomData.data) { 108 | this.setAtomCache(query, atomiAtomContainer); 109 | return; 110 | } 111 | 112 | // flattens query 113 | const flattenedQuery = flattenQuery(atomiAtomContainer.atomData.data); 114 | 115 | this.setQueryAtomMap(flattenedQuery, query); 116 | 117 | // if the cache is not empty, then check if any atoms need to be updated 118 | if (Object.keys(this.cacheContainer.atomCache).length) { 119 | this.updateAtomsFromCache(query, flattenedQuery); 120 | } 121 | 122 | // sets the cache in the atom 123 | this.setAtomCache(query, atomiAtomContainer); 124 | 125 | // sets the flattened cache 126 | this.setNodeCache(flattenedQuery); 127 | }; 128 | 129 | // Store links between gql nodes and atoms by query key 130 | setQueryAtomMap = (flattenedQuery: ResponseData, query: string) => { 131 | for (const queryNode in flattenedQuery) { 132 | if (!this.cacheContainer.queryAtomMap[queryNode]) { 133 | this.cacheContainer.queryAtomMap[queryNode] = new Set([query]); 134 | } else { 135 | this.cacheContainer.queryAtomMap[queryNode].add(query); 136 | } 137 | } 138 | }; 139 | 140 | // stores new query atom data into the cache 141 | setAtomCache = (query: string, atomiAtomContainer: AtomiAtomContainer) => { 142 | this.cacheContainer.atomCache = { 143 | ...this.cacheContainer.atomCache, 144 | [query]: atomiAtomContainer, 145 | }; 146 | }; 147 | 148 | // Store in a node cache data for each gql object received from the server 149 | setNodeCache = (flattenedQueryData: ResponseData | null) => { 150 | this.cacheContainer.gqlNodeCache = { 151 | ...this.cacheContainer.gqlNodeCache, 152 | ...flattenedQueryData, 153 | }; 154 | }; 155 | 156 | // iterates through the existing flattened node cache, performs a deep equality scan to check if any differences exist in any object, returns a list of atoms with differences, then calls requery on them 157 | updateAtomsFromCache = (query: string, flattenedQuery: ResponseData) => { 158 | const atomsToUpdate: Set = new Set(); 159 | Object.keys(flattenedQuery).forEach((queryNodeId: string) => { 160 | if ( 161 | !isEqual( 162 | flattenedQuery[queryNodeId], 163 | this.cacheContainer.gqlNodeCache[queryNodeId] 164 | ) 165 | ) { 166 | this.cacheContainer.queryAtomMap[queryNodeId].forEach((atomString) => { 167 | if (atomString !== query) atomsToUpdate.add(atomString); 168 | }); 169 | } 170 | }); 171 | 172 | atomsToUpdate.forEach((atomQuery: string) => { 173 | this.reQuery(atomQuery); 174 | }); 175 | }; 176 | 177 | // queries the server given a query string 178 | reQuery = async (query: string) => { 179 | const { graphQLClient, typeDefs, resolvers } = this.cacheContainer; 180 | if (this.cacheContainer.atomCache[query]) { 181 | const atomiAtomContainer = this.cacheContainer.atomCache[query]; 182 | const { originalQuery, variables } = atomiAtomContainer; 183 | const { 184 | updatedAST, 185 | strippedQuery, 186 | pathToResolvers, 187 | foundClientDirective, 188 | sendQueryToServer, 189 | } = parseQuery(originalQuery); 190 | const res = await getQueryResult( 191 | sendQueryToServer, 192 | graphQLClient, 193 | updatedAST, 194 | variables, 195 | foundClientDirective, 196 | typeDefs, 197 | resolvers, 198 | pathToResolvers, 199 | strippedQuery 200 | ); 201 | this.writeAtom(atomiAtomContainer, res); 202 | } 203 | }; 204 | 205 | // Get the atom container for a certain query 206 | getAtomiAtomContainer = (query: string): AtomiAtomContainer => { 207 | const atomiAtomContainer = this.cacheContainer.atomCache[query]; 208 | // If we cannot find the atom container, throw an error 209 | return atomiAtomContainer; 210 | }; 211 | 212 | isQueryCached = (query: string): boolean => 213 | !!this.cacheContainer.atomCache[query]; 214 | 215 | // Update the value of the atoms associated with a certain query 216 | writeQuery = (queryInput: string, newData: any, variables?: any) => { 217 | const { queryString: query } = parseQuery(queryInput); 218 | // Get the atom container associated with the query 219 | let atomiAtomContainer = this.getAtomiAtomContainer(query); 220 | // If the query is cached and setAtom is set 221 | if (atomiAtomContainer && atomiAtomContainer.setAtom) { 222 | // Overwrite the atom the with the new data 223 | // Set loading to false as we have set the data 224 | this.writeAtom(atomiAtomContainer, newData, false); 225 | } else { 226 | // If query does not exist in the cache, set the cache 227 | const newAtomData: AtomData = { 228 | data: newData, 229 | loading: false, 230 | hasError: false, 231 | }; 232 | // AtomContainer not cached, so create it. 233 | atomiAtomContainer = { 234 | originalQuery: queryInput, 235 | variables, 236 | atom: atom(newAtomData), 237 | atomData: { 238 | loading: false, 239 | hasError: false, 240 | data: newAtomData, 241 | }, 242 | setAtom: undefined, 243 | }; 244 | // Store it in the cache 245 | this.setCache(query, atomiAtomContainer); 246 | } 247 | }; 248 | 249 | // Use this function to write/update the value of any Atoms 250 | // DO NOT USE setAtom directly 251 | writeAtom = ( 252 | atomiAtomContainer: AtomiAtomContainer, 253 | newData: any, 254 | loading?: boolean 255 | ) => { 256 | const { atomData, setAtom } = atomiAtomContainer; 257 | // Update the atomData.data value with the newData 258 | // We do this so that we can access the atomData without invoking the useAtom hook 259 | // This is because the useAtom hook can only be invoked at the top of a react function component 260 | atomData.data = newData; 261 | if (typeof loading !== 'undefined') atomData.loading = loading; 262 | // Then update the atom itself with the new data 263 | if (setAtom) { 264 | setAtom((oldAtomData: AtomData) => ({ 265 | ...oldAtomData, 266 | loading: typeof loading === 'undefined' ? oldAtomData.loading : loading, 267 | data: newData, 268 | })); 269 | } else { 270 | // eslint-disable-next-line no-console 271 | console.error('Cannot writeAtom if setAtom is undefined.'); 272 | // eslint-disable-next-line no-console 273 | throw new Error('Cannot writeAtom if setAtom is undefined.'); 274 | } 275 | }; 276 | 277 | // Read the data and get the writeAtom function associated with a certain query 278 | readQuery = (query: string): ReadQueryOutput => { 279 | // Parse the query into a reliable format 280 | const { queryString } = parseQuery(query); 281 | // Get the atom container 282 | const atomiAtomContainer = this.getAtomiAtomContainer(queryString); 283 | if (!atomiAtomContainer) { 284 | // If the query is not cached, you cannot read it 285 | // eslint-disable-next-line no-console 286 | console.error('Query not cached'); 287 | throw new Error('Query not cached'); 288 | } 289 | // Extract the data from the atom container 290 | const { data } = atomiAtomContainer.atomData; 291 | // Create the writeAtom function for this this particular atom 292 | const writeAtom = (newData: any) => 293 | this.writeAtom(atomiAtomContainer, newData); 294 | // Pass the atom data and the writeAtom function to the user 295 | return { 296 | data, 297 | writeAtom, 298 | }; 299 | }; 300 | 301 | render() { 302 | const { children } = this.props; 303 | return ( 304 | 305 | {children} 306 | 307 | ); 308 | } 309 | } 310 | --------------------------------------------------------------------------------