├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demo ├── custom.d.ts ├── index.html ├── index.tsx ├── queries.ts ├── tsconfig.json └── webpack.demo.js ├── docs ├── gender_stats.png ├── gqlodash-logo.png ├── lodash.gif ├── people_to_films.png ├── planet_with_max_population.png └── relay-architecture.png ├── package.json ├── src ├── index.ts ├── lodash_idl.ts └── transformations.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | 40 | # output 41 | /lib 42 | /demo/bundle* 43 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !/lib/** 3 | !/package.json 4 | !LICENSE 5 | !README.md 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GraphQL Lodash logo](docs/gqlodash-logo.png) 2 | 3 | # GraphQL Lodash 4 | [![npm](https://img.shields.io/npm/v/graphql-lodash.svg)](https://www.npmjs.com/package/graphql-lodash) [![David](https://img.shields.io/david/APIs-guru/graphql-lodash.svg)](https://david-dm.org/APIs-guru/graphql-lodash) 5 | [![David](https://img.shields.io/david/dev/APIs-guru/graphql-lodash.svg)](https://david-dm.org/APIs-guru/graphql-lodash?type=dev) 6 | [![npm](https://img.shields.io/npm/l/graphql-lodash.svg)](https://github.com/APIs-guru/graphql-lodash/blob/master/LICENSE) 7 | 8 | Unleash the power of [lodash](https://lodash.com/) inside your GraphQL queries 9 | 10 | #### Table of contents 11 | - [Why?](#why) 12 | - [Example queries](#example-queries) 13 | - [API](#api) 14 | - [Usage Examples](#usage-examples) 15 | - [`fetch`](#fetch-example) 16 | - [Caching clients](#caching-clients) 17 | - [**Usage with react-apollo**](#usage-with-react-apollo) 18 | - [Usage on server-side](#usage-on-server-side) (tl;dr **don't**) 19 | 20 | ## Why? 21 | GraphQL allows to ask for what you need and get exactly that. But what about the shape? 22 | GraphQL Lodash gives you the power of `lodash` right inside your GraphQL Query using `@_` directive. 23 | 24 | [![lodash usage gif](docs/lodash.gif)](https://apis.guru/graphql-lodash/) 25 | 26 | **Note**: This is an **experimental** project created to explore the concept of **Query and transformation collocation**. 27 | 28 | We encourage you to try it inside our [demo](https://apis.guru/graphql-lodash/) or check detailed [walkthrough](https://docs.google.com/presentation/d/1aBXjC98hfYrbjUKlWGFMWgAMh9FcxeW_w97uatNYXls/pub?start=false&loop=false&delayms=3000). 29 | 30 | ## Example queries 31 | Here are a few query examples you can run against StartWars API: 32 | 33 | #### Find the planet with the biggest population 34 | ![Find the planet with the biggest population](docs/planet_with_max_population.png) 35 | #### Get gender statistics 36 | ![Get gender statistics](docs/gender_stats.png) 37 | #### Map characters to films they are featured in 38 | ![Map characters to films they are featured in](docs/people_to_films.png) 39 | 40 | ## Install 41 | 42 | npm install --save graphql-lodash 43 | or 44 | 45 | yarn add graphql-lodash 46 | 47 | ## API 48 | 49 | ### `graphqlLodash(query, [operationName])` 50 | 51 | - **query** (_required_) - query string or query AST 52 | - **operationName** (_optional_) - required only if the query contains multiple operations 53 | 54 | ### Returns 55 | ``` 56 | { 57 | query: string|object, 58 | transform: Function 59 | } 60 | ``` 61 | - **query** - the original query with stripped `@_` directives 62 | - **transform** - function that receives `response.data` as a single argument and returns 63 | the same data in the intended shape. 64 | 65 | 66 | 67 | ## Usage Examples 68 | 69 | The simplest way to integrate `graphql-lodash` is to write wrapper function for graphql client of you choice: 70 | ```js 71 | import { graphqlLodash } from 'graphql-lodash'; 72 | 73 | function lodashQuery(queryWithLodash) { 74 | let { query, transform } = graphqlLodash(queryWithLodash); 75 | // Make a GraphQL call using 'query' variable as a query 76 | // And place result in 'result' variable 77 | ... 78 | result.data = transform(result.data); 79 | return result; 80 | } 81 | ``` 82 | 83 | ### Fetch example 84 | An example of a simple client based on [fetch API](https://developer.mozilla.org/en/docs/Web/API/Fetch_API): 85 | ```js 86 | function executeGraphQLQuery(url, query) { 87 | return fetch(url, { 88 | method: 'POST', 89 | headers: new Headers({"content-type": 'application/json'}), 90 | body: JSON.stringify({ query: query }) 91 | }).then(response => { 92 | if (response.ok) 93 | return response.json(); 94 | return response.text().then(body => { 95 | throw Error(response.status + ' ' + response.statusText + '\n' + body); 96 | }); 97 | }); 98 | } 99 | 100 | function lodashQuery(url, queryWithLodash) { 101 | let { query, transform } = window.GQLLodash.graphqlLodash(queryWithLodash); 102 | return executeGraphQLQuery(url, query).then(result => { 103 | result.data = transform(result.data); 104 | return result; 105 | }); 106 | } 107 | 108 | // then use as bellow 109 | lodashQuery('https://swapi.apis.guru', `{ 110 | planetWithMaxPopulation: allPlanets @_(get: "planets") { 111 | planets @_(maxBy: "population") { 112 | name 113 | population 114 | } 115 | } 116 | }`).then(result => console.log(result.data)); 117 | ``` 118 | 119 | ### Caching clients 120 | For caching clients like Relay and Apollo we recommend to apply the transformation after the caching layer. 121 | Here is proposed solution for Relay: 122 | 123 | ![Relay usage](docs/relay-architecture.png) 124 | 125 | We are still figuring out how to do this and any [feedback](https://github.com/APIs-guru/graphql-lodash/issues/new) is welcome. 126 | 127 | #### Usage with [react-apollo](https://github.com/apollographql/react-apollo) 128 | 129 | When using with Apollo you can use `props` option to apply transformations: 130 | 131 | ```js 132 | const rawQuery = gql` 133 | # query with @_ directives 134 | `; 135 | 136 | const {query, transform} = graphqlLodash(rawQuery); 137 | export default graphql(query, { 138 | props: (props) => ({...props, rawData: props.data, data: transform(props.data)}) 139 | })(Component); 140 | ``` 141 | 142 | You can write a simple wrapper for simplicity: 143 | 144 | ```js 145 | import { graphql } from 'react-apollo'; 146 | import { graphqlLodash } from 'graphql-lodash'; 147 | 148 | export function gqlLodash(rawQuery, config) { 149 | const {query, transform} = graphqlLodash(rawQuery); 150 | let origProps = (config && config.props) || ((props) => props); 151 | 152 | return (comp) => graphql(query, {...config, 153 | props: (props) => origProps({ 154 | ...props, 155 | rawData: props.data, 156 | data: transform(props.data) 157 | }) 158 | })(comp); 159 | } 160 | // then use as bellow 161 | export default gqlLodash(query)(Component); 162 | ``` 163 | 164 | Just replace `graphql` with `gqlLodash` and you are ready to use lodash in your queries. 165 | Check out the [react-apollo-lodash-demo](https://github.com/APIs-guru/react-apollo-lodash-demo) repo. 166 | 167 | You can also do the transformation inside an [Apollo 168 | Link](https://www.apollographql.com/docs/link/) by rewriting the 169 | parsed GraphQL `Document`: 170 | 171 | ```js 172 | new ApolloLink((operation, forward) => { 173 | const { query, transform } = graphqlLodash(operation.query); 174 | operation.query = query; 175 | return forward(operation) 176 | .map(response => ({ 177 | ...response, 178 | data: transform(response.data), 179 | })); 180 | }); 181 | ``` 182 | 183 | Chaining this link with the other links passed to your `ApolloClient` 184 | will apply the transformation to every query that 185 | Apollo runs, such as those from the `` component or 186 | subscriptions. 187 | 188 | ### Introspection queries 189 | 190 | If your application uses introspection queries (like GraphiQL does to 191 | get documentation and autocomplete information), you will also need to 192 | extend the introspection query result with the directives from 193 | graphql-lodash. One way you could do this is: 194 | 195 | ```js 196 | import { 197 | buildClientSchema, 198 | extendSchema, 199 | graphqlSync, 200 | introspectionQuery, 201 | } from 'graphql'; 202 | 203 | // inside the above ApolloLink function 204 | if (response.data && response.data.__schema) { 205 | const schema = extendSchema( 206 | buildClientSchema(response.data), 207 | lodashDirectiveAST, 208 | ); 209 | return graphqlSync(schema, introspectionQuery); 210 | } 211 | ``` 212 | 213 | See the `demo/` source in this repo for another example of modifying 214 | the introspection query result. 215 | 216 | ## Usage on server side 217 | 218 | In theory, this tool can be used on the server. But this will break the contract and, most likely, 219 | will break all the GraphQL tooling you use. Use it on server-side only if you know what you do. 220 | -------------------------------------------------------------------------------- /demo/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | var content: any; 3 | export = content; 4 | } 5 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 34 | GraphQL Lodash by APIs.guru 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 59 | 60 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import fetch from 'isomorphic-fetch'; 5 | import Modal from 'react-modal'; 6 | 7 | import { 8 | extendSchema, 9 | buildClientSchema, 10 | introspectionQuery, 11 | } from 'graphql'; 12 | 13 | import { GraphiQLTab, AppConfig, TabConfig } from 'graphiql-workspace'; 14 | 15 | import 'graphiql-workspace/graphiql-workspace.css'; 16 | import 'graphiql/graphiql.css'; 17 | 18 | 19 | import { graphqlLodash, lodashDirectiveAST } from '../src/'; 20 | import { defaultQuery, savedQueries } from './queries'; 21 | 22 | // monkey patch updateSchema 23 | // TODO: 24 | GraphiQLTab.prototype.updateSchema = function() { 25 | const fetch = this.fetcher({query: introspectionQuery}); 26 | 27 | return fetch.then(result => { 28 | if (result && result.data) { 29 | let schema = buildClientSchema(result.data); 30 | schema = extendSchema(schema, lodashDirectiveAST); 31 | this.setState({schema, schemaError: false}); 32 | } else { 33 | this.setState({schemaError: true}); 34 | } 35 | }).catch(_ => { 36 | this.setState({schemaError: true}); 37 | }); 38 | } 39 | 40 | const workspaceOptions = { 41 | defaultQuery, 42 | defaultSavedQueries: 43 | 44 | savedQueries 45 | }; 46 | 47 | const defaultTabConfig = { 48 | name:'', 49 | url: 'https://swapi.apis.guru', 50 | headers: [], 51 | query: defaultQuery, 52 | maxHistory: 10, 53 | variables: '' 54 | }; 55 | 56 | export class Demo extends React.Component<{}, any> { 57 | constructor(props) { 58 | super(props); 59 | let config = new AppConfig("graphiql", workspaceOptions); 60 | 61 | let tab = new TabConfig('tab', defaultTabConfig); 62 | 63 | this.state = { 64 | config, tab, 65 | modalIsOpen: !('lodash-demo:helpModalWasClosed' in localStorage) 66 | } 67 | } 68 | 69 | onToolbar(action) { 70 | if (action == "clean") { 71 | this.state.appConfig.cleanup(); 72 | this.state.config.cleanup(); 73 | const appConfig = new AppConfig("graphiql", workspaceOptions) 74 | let config = new TabConfig('tab', defaultTabConfig); 75 | 76 | this.setState({ 77 | appConfig, 78 | config, 79 | queryUpdate: { 80 | query: workspaceOptions.defaultQuery 81 | } 82 | }) 83 | } 84 | } 85 | 86 | fetcher(graphQLParams, {url, headers}:{url: string, headers:Map}) { 87 | const {query, transform} = graphqlLodash( 88 | graphQLParams.query, 89 | graphQLParams.operationName 90 | ); 91 | 92 | return fetch(url, { 93 | method: 'POST', 94 | headers, 95 | credentials: 'omit', 96 | body: JSON.stringify({...graphQLParams, query}) 97 | }).then(responce => { 98 | if (responce.ok) 99 | return responce.json(); 100 | return responce.text().then(body => { 101 | let err; 102 | try { 103 | err = JSON.parse(body); 104 | } catch(e) { 105 | throw body; 106 | } 107 | throw err; 108 | }); 109 | }).then(result => ({ 110 | ...result, 111 | data: transform(result.data) 112 | })) 113 | } 114 | 115 | closeModal = () => { 116 | this.setState({ 117 | ...this.state, 118 | modalIsOpen: false, 119 | }); 120 | 121 | localStorage.setItem('lodash-demo:helpModalWasClosed', 'true'); 122 | } 123 | 124 | render() { 125 | const modalStyles = { 126 | overlay : { 127 | zIndex: '100', 128 | backgroundColor: 'rgba(0, 0, 0, 0.75)' 129 | }, 130 | content : { 131 | position: 'absolute', 132 | top: '50%', 133 | left: '50%', 134 | right: 'auto', 135 | bottom: 'auto', 136 | width: '960px', 137 | maxWidth: '90%', 138 | height: '569px', 139 | overflow: 'hidden', 140 | border: 0, 141 | padding: 0, 142 | maxHeight: '90vh', 143 | transform: 'translate(-50%, -50%)' 144 | } 145 | }; 146 | 147 | const closeButtonStyles:React.CSSProperties = { 148 | fontSize: '40px', 149 | lineHeight: '33px', 150 | height: '40px', 151 | verticalAlign: 'middle', 152 | width: '40px', 153 | textAlign: 'center', 154 | top: '10px', 155 | right: '10px', 156 | background: 'white', 157 | borderRadius: '100%', 158 | position: 'absolute', 159 | cursor: 'pointer' 160 | }; 161 | 162 | return ( 163 |
164 | 165 | 171 | × 172 | 173 | 174 |
175 | ) 176 | } 177 | } 178 | 179 | ReactDOM.render(, document.getElementById('container')); 180 | -------------------------------------------------------------------------------- /demo/queries.ts: -------------------------------------------------------------------------------- 1 | export const defaultQuery = ` 2 | # This is sample query with @_ directives which change the shape of response 3 | # Run this query to see the resutls! 4 | # More examples are hidden under Saved Queries dropdown above 5 | { 6 | genderStats: allPeople @_(get: "people") { 7 | people @_(countBy: "gender") { 8 | gender 9 | } 10 | } 11 | }`.trim(); 12 | 13 | const maxPopulationQuery = ` 14 | # Planet With Max Population 15 | { 16 | planetWithMaxPopulation: allPlanets @_(get: "planets") { 17 | planets @_(maxBy: "population") { 18 | name 19 | population 20 | } 21 | } 22 | }`.trim(); 23 | 24 | const genderStatsQuery = ` 25 | # Gender Stats 26 | { 27 | genderStats: allPeople @_(get: "people") { 28 | people @_(countBy: "gender") { 29 | gender 30 | } 31 | } 32 | }`.trim(); 33 | 34 | const mapPeopleToFilmsQuery = ` 35 | # Map People To Films 36 | { 37 | peopleToFilms: allPeople @_(get: "people") { 38 | people @_( 39 | keyBy: "name" 40 | mapValues: "filmConnection.films" 41 | ) { 42 | name 43 | filmConnection { 44 | films @_(map: "title") { 45 | title 46 | } 47 | } 48 | } 49 | } 50 | }`.trim(); 51 | 52 | export const savedQueries = [ 53 | { query: maxPopulationQuery, variables: "" }, 54 | { query: genderStatsQuery, variables: "" }, 55 | { query: mapPeopleToFilmsQuery, variables: "" } 56 | ]; 57 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "sourceMap": true, 6 | "target": "es5", 7 | "noImplicitAny": false, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "outDir": "dist", 11 | "moduleResolution": "node", 12 | "lib": ["es2017", "dom"], 13 | "jsx": "react", 14 | "esModuleInterop": true 15 | }, 16 | "include": ["./custom.d.ts", "**/*.tsx", "**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /demo/webpack.demo.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | 4 | module.exports = { 5 | devtool: 'source-map', 6 | devServer: { 7 | contentBase: __dirname, 8 | watchContentBase: true, 9 | port: 9005, 10 | }, 11 | entry: './demo/index.tsx', 12 | plugins: [new MiniCssExtractPlugin({ filename: 'bundle.css' })], 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.tsx?$/, 17 | use: 'ts-loader', 18 | exclude: /node_modules/, 19 | }, 20 | { 21 | test: /\.css$/, 22 | use: [ 23 | 'style-loader', 24 | MiniCssExtractPlugin.loader, 25 | { loader: 'css-loader', options: { importLoaders: 1 } }, 26 | ], 27 | }, 28 | ], 29 | }, 30 | resolve: { 31 | extensions: ['.tsx', '.ts', '.mjs', '.js'], 32 | }, 33 | output: { 34 | path: __dirname, 35 | filename: 'bundle.js', 36 | sourceMapFilename: '[file].map' 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /docs/gender_stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-kit/graphql-lodash/c605db6124a8e9975b7c8032289564241680c41b/docs/gender_stats.png -------------------------------------------------------------------------------- /docs/gqlodash-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-kit/graphql-lodash/c605db6124a8e9975b7c8032289564241680c41b/docs/gqlodash-logo.png -------------------------------------------------------------------------------- /docs/lodash.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-kit/graphql-lodash/c605db6124a8e9975b7c8032289564241680c41b/docs/lodash.gif -------------------------------------------------------------------------------- /docs/people_to_films.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-kit/graphql-lodash/c605db6124a8e9975b7c8032289564241680c41b/docs/people_to_films.png -------------------------------------------------------------------------------- /docs/planet_with_max_population.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-kit/graphql-lodash/c605db6124a8e9975b7c8032289564241680c41b/docs/planet_with_max_population.png -------------------------------------------------------------------------------- /docs/relay-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-kit/graphql-lodash/c605db6124a8e9975b7c8032289564241680c41b/docs/relay-architecture.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-lodash", 3 | "description": "GraphQL Lodash", 4 | "keywords": [ 5 | "GraphQL", 6 | "lodash", 7 | "underscore", 8 | "fp", 9 | "functional" 10 | ], 11 | "license": "MIT", 12 | "author": "APIs.guru ", 13 | "homepage": "https://github.com/APIs-guru/graphql-lodash#readme", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/APIs-guru/graphql-lodash.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/APIs-guru/graphql-lodash/issues" 20 | }, 21 | "version": "1.3.4", 22 | "main": "lib/graphql-lodash.bundle.js", 23 | "module": "lib/index.js", 24 | "jsnext:main": "lib/index.js", 25 | "types": "lib/index.d.ts", 26 | "scripts": { 27 | "start": "webpack-dev-server --config demo/webpack.demo.js", 28 | "build:demo": "webpack -p --config demo/webpack.demo.js", 29 | "deploy": "deploy-to-gh-pages --local demo", 30 | "tsc": "tsc", 31 | "build:bundle": "webpack -p --config webpack.config.js", 32 | "build:package": "rimraf lib/ && npm run tsc && npm run build:bundle" 33 | }, 34 | "dependencies": { 35 | "graphql": "^0.11.7", 36 | "lodash-es": "^4.17.15" 37 | }, 38 | "devDependencies": { 39 | "@types/graphql": "0.11.8", 40 | "@types/lodash-es": "4.17.3", 41 | "@types/react": "15.6.28", 42 | "@types/react-dom": "15.5.12", 43 | "css-loader": "4.2.1", 44 | "deploy-to-gh-pages": "1.3.7", 45 | "graphiql": "0.11.10", 46 | "graphiql-workspace": "github:apis-guru/graphiql-workspace#tab-fork-dist", 47 | "mini-css-extract-plugin": "^0.10.0", 48 | "react": "15.5.4", 49 | "react-dom": "15.5.4", 50 | "react-modal": "3.11.2", 51 | "rimraf": "3.0.2", 52 | "style-loader": "1.2.1", 53 | "ts-loader": "^8.0.3", 54 | "typescript": "4.0.2", 55 | "webpack": "4.44.1", 56 | "webpack-cli": "^3.3.12", 57 | "webpack-dev-server": "3.11.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Source, 3 | Kind, 4 | parse, 5 | visit, 6 | print, 7 | DocumentNode, 8 | } from 'graphql/language'; 9 | 10 | import { GraphQLError } from 'graphql/error/GraphQLError'; 11 | 12 | import { getOperationAST } from 'graphql/utilities/getOperationAST'; 13 | import { concatAST } from 'graphql/utilities/concatAST'; 14 | import { buildASTSchema } from 'graphql/utilities/buildASTSchema'; 15 | 16 | import { 17 | getArgumentValues, 18 | } from 'graphql/execution/values'; 19 | 20 | import get from 'lodash-es/get'; 21 | import set from 'lodash-es/set'; 22 | import each from 'lodash-es/each'; 23 | import keyBy from 'lodash-es/keyBy'; 24 | import isEqual from 'lodash-es/isEqual'; 25 | 26 | import { applyTransformations } from './transformations'; 27 | 28 | import { lodashIDL } from './lodash_idl'; 29 | 30 | export function graphqlLodash(query: string | DocumentNode, operationName?: string) { 31 | const pathToArgs = {}; 32 | const queryAST = typeof query === 'string' ? parse(query) : query; 33 | traverseOperationFields(queryAST, operationName, (node, resultPath) => { 34 | var args = getLodashDirectiveArgs(node); 35 | if (args === null) 36 | return; 37 | 38 | // TODO: error if transformation applied on field that already 39 | // seen without any transformation 40 | const argsSetPath = [...resultPath, '@_']; 41 | const previousArgsValue = get(pathToArgs, argsSetPath, null); 42 | if (previousArgsValue !== null && !isEqual(previousArgsValue, args)) 43 | throw Error(`Different "@_" args for the "${resultPath.join('.')}" path`); 44 | set(pathToArgs, argsSetPath, args); 45 | }); 46 | 47 | const stripedQuery = stripQuery(queryAST); 48 | return { 49 | query: typeof query === 'string' ? print(stripedQuery) : stripedQuery, 50 | transform: data => applyLodashDirective(pathToArgs, data) 51 | }; 52 | } 53 | 54 | function getLodashDirectiveArgs(node) { 55 | let lodashNode = null; 56 | 57 | for (let directive of node.directives || []) { 58 | if (directive.name.value !== lodashDirectiveDef.name) 59 | continue; 60 | if (lodashNode) 61 | throw Error(`Duplicating "@_" on the "${node.name.value}"`); 62 | lodashNode = directive; 63 | } 64 | 65 | if (lodashNode === null) 66 | return null; 67 | 68 | const args = getArgumentValues(lodashDirectiveDef, lodashNode); 69 | return normalizeLodashArgs(lodashNode.arguments, args); 70 | } 71 | 72 | function normalizeLodashArgs(argNodes, args) { 73 | if (!argNodes) 74 | return args; 75 | 76 | //Restore order of arguments 77 | argNodes = keyBy(argNodes, argNode => argNode.name.value); 78 | const orderedArgs = {}; 79 | each(argNodes, (node, name) => { 80 | const argValue = args[name]; 81 | 82 | if (node.value.kind === 'ObjectValue') 83 | orderedArgs[name] = normalizeLodashArgs(node.value.fields, argValue); 84 | else if (node.value.kind === 'ListValue') { 85 | const nodeValues = node.value.values; 86 | 87 | orderedArgs[name] = []; 88 | for (let i = 0; i < nodeValues.length; ++i) 89 | orderedArgs[name][i] = normalizeLodashArgs(nodeValues[i].fields, argValue[i]); 90 | } 91 | else if (node.value.kind === 'EnumValue' && node.value.value === 'none') 92 | orderedArgs[name] = undefined; 93 | else 94 | orderedArgs[name] = argValue; 95 | }); 96 | return orderedArgs; 97 | } 98 | 99 | function applyLodashDirective(pathToArgs, data) { 100 | if (data === null) 101 | return null; 102 | 103 | const changedData = applyOnPath(data, pathToArgs); 104 | return applyLodashArgs([], changedData, pathToArgs['@_']); 105 | } 106 | 107 | function applyLodashArgs(path, object, args) { 108 | try { 109 | return applyTransformations(object, args); 110 | } catch (e) { 111 | // FIXME: 112 | console.log(path); 113 | throw e; 114 | } 115 | } 116 | 117 | function applyOnPath(result, pathToArgs) { 118 | const currentPath = []; 119 | return traverse(result, pathToArgs); 120 | 121 | function traverse(root, pathRoot) { 122 | if (root === null || root === undefined) 123 | return null; 124 | if (Array.isArray(root)) 125 | return root.map(item => traverse(item, pathRoot)); 126 | 127 | if (typeof root === 'object') { 128 | const changedObject = Object.assign({}, root); 129 | for (const key in pathRoot) { 130 | if (key === '@_') 131 | continue; 132 | currentPath.push(key); 133 | 134 | let changedValue = traverse(root[key], pathRoot[key]); 135 | if (changedValue === null || changedValue === undefined) 136 | continue; 137 | 138 | const lodashArgs = pathRoot[key]['@_']; 139 | changedValue = applyLodashArgs(currentPath, changedValue, lodashArgs); 140 | changedObject[key] = changedValue; 141 | currentPath.pop(); 142 | } 143 | return changedObject; 144 | } else { 145 | return root; 146 | } 147 | } 148 | } 149 | 150 | function stripQuery(queryAST): DocumentNode { 151 | return visit(queryAST, { 152 | [Kind.DIRECTIVE]: (node) => { 153 | if (node.name.value === '_') 154 | return null; 155 | }, 156 | }); 157 | } 158 | 159 | export const lodashDirectiveAST: DocumentNode = parse(new Source(lodashIDL, 'lodashIDL')); 160 | const lodashDirectiveDef = getDirectivesFromAST(lodashDirectiveAST)[0]; 161 | 162 | function getDirectivesFromAST(ast) { 163 | const dummyIDL = ` 164 | type Query { 165 | dummy: String 166 | } 167 | `; 168 | const fullAST = concatAST([ast, parse(dummyIDL)]); 169 | const schema = buildASTSchema(fullAST); 170 | 171 | (schema.getTypeMap()['Path'] as any).parseLiteral = (x => x.value); 172 | (schema.getTypeMap()['JSON'] as any).parseLiteral = astToJSON; 173 | 174 | return schema.getDirectives(); 175 | } 176 | 177 | // TODO: copy-pasted from JSON Faker move to graphql-js or separate lib 178 | function astToJSON(ast) { 179 | switch (ast.kind) { 180 | case Kind.NULL: 181 | return null; 182 | case Kind.INT: 183 | return parseInt(ast.value, 10); 184 | case Kind.FLOAT: 185 | return parseFloat(ast.value); 186 | case Kind.STRING: 187 | case Kind.BOOLEAN: 188 | return ast.value; 189 | case Kind.LIST: 190 | return ast.values.map(astToJSON); 191 | case Kind.OBJECT: 192 | return ast.fields.reduce((object, { name, value }) => { 193 | object[name.value] = astToJSON(value); 194 | return object; 195 | }, {}); 196 | } 197 | } 198 | 199 | function traverseOperationFields(queryAST, operationName, cb) { 200 | const fragments = {}; 201 | const operationAST = getOperationAST(queryAST, operationName); 202 | if (!operationAST) { 203 | throw new GraphQLError( 204 | 'Must provide operation name if query contains multiple operations.' 205 | ); 206 | } 207 | 208 | queryAST.definitions.forEach(definition => { 209 | if (definition.kind === Kind.FRAGMENT_DEFINITION) 210 | fragments[definition.name.value] = definition; 211 | }); 212 | 213 | const resultPath = []; 214 | cb(operationAST, resultPath); 215 | traverse(operationAST); 216 | 217 | function traverse(root) { 218 | visit(root, { 219 | enter(node) { 220 | if (node.kind === Kind.FIELD) 221 | resultPath.push((node.alias || node.name).value); 222 | 223 | if (node.kind === Kind.FRAGMENT_SPREAD) { 224 | const fragmentName = node.name.value; 225 | const fragment = fragments[fragmentName]; 226 | if (!fragment) 227 | throw Error(`Unknown fragment: ${fragmentName}`); 228 | traverse(fragment); 229 | } 230 | }, 231 | leave(node) { 232 | if (node.kind !== Kind.FIELD) 233 | return; 234 | cb(node, resultPath); 235 | resultPath.pop(); 236 | } 237 | }); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/lodash_idl.ts: -------------------------------------------------------------------------------- 1 | const lodashProps = ` 2 | map: Path 3 | keyBy: Path 4 | each: LodashOperations 5 | 6 | # Creates an array of elements split into groups the length of size. 7 | # If array can't be split evenly, the final chunk will be the remaining elements. 8 | chunk: Int 9 | 10 | # Creates a slice of array with n elements dropped from the beginning. 11 | drop: Int 12 | 13 | # Creates a slice of array with n elements dropped from the end. 14 | dropRight: Int 15 | 16 | # Creates a slice of array with n elements taken from the beginning. 17 | take: Int 18 | 19 | # Creates a slice of array with n elements taken from the end. 20 | takeRight: Int 21 | 22 | # Recursively flatten array up to depth times. 23 | flattenDepth: Int 24 | 25 | # The inverse of \`toPairs\`; this method returns an object composed from key-value 26 | # pairs. 27 | fromPairs: DummyArgument 28 | 29 | # Gets the element at index n of array. If n is negative, the nth element from 30 | # the end is returned. 31 | nth: Int 32 | 33 | # Reverses array so that the first element becomes the last, the second element 34 | # becomes the second to last, and so on. 35 | reverse: DummyArgument 36 | 37 | # Creates a duplicate-free version of an array, in which only the first occurrence 38 | # of each element is kept. The order of result values is determined by the order 39 | # they occur in the array. 40 | uniq: DummyArgument 41 | 42 | uniqBy: Path 43 | 44 | countBy: Path 45 | filter: JSON 46 | reject: JSON 47 | filterIf: Predicate 48 | rejectIf: Predicate 49 | groupBy: Path 50 | sortBy: [Path!] 51 | 52 | minBy: Path 53 | maxBy: Path 54 | meanBy: Path 55 | sumBy: Path 56 | 57 | # Converts all elements in array into a string separated by separator. 58 | join: String 59 | 60 | get: Path 61 | mapValues: Path 62 | 63 | # Creates an array of values corresponding to paths of object. 64 | at: [Path!] 65 | # Creates an array of own enumerable string keyed-value pairs for object. 66 | toPairs: DummyArgument 67 | 68 | # Creates an object composed of the inverted keys and values of object. 69 | # If object contains duplicate values, subsequent values overwrite property 70 | # assignments of previous values. 71 | invert: DummyArgument 72 | 73 | invertBy: Path 74 | # Creates an array of the own enumerable property names of object. 75 | keys: DummyArgument 76 | # Creates an array of the own enumerable string keyed property values of object. 77 | values: DummyArgument 78 | `; 79 | 80 | export const lodashIDL = ` 81 | scalar Path 82 | scalar JSON 83 | 84 | enum DummyArgument { 85 | none 86 | } 87 | 88 | input Predicate { 89 | lt: JSON 90 | lte: JSON 91 | gt: JSON 92 | gte: JSON 93 | eq: JSON 94 | startsWith: String 95 | endsWith: String 96 | and: [Predicate!] 97 | or: [Predicate!] 98 | ${lodashProps} 99 | } 100 | 101 | directive @_( 102 | ${lodashProps} 103 | ) on FIELD | QUERY 104 | 105 | input LodashOperations { 106 | ${lodashProps} 107 | } 108 | `; 109 | -------------------------------------------------------------------------------- /src/transformations.ts: -------------------------------------------------------------------------------- 1 | import every from 'lodash-es/every'; 2 | import some from 'lodash-es/some'; 3 | import startsWith from 'lodash-es/startsWith'; 4 | import endsWith from 'lodash-es/endsWith'; 5 | import lt from 'lodash-es/lt'; 6 | import lte from 'lodash-es/lte'; 7 | import gt from 'lodash-es/gt'; 8 | import gte from 'lodash-es/gte'; 9 | import eq from 'lodash-es/eq'; 10 | import map from 'lodash-es/map'; 11 | import keyBy from 'lodash-es/keyBy'; 12 | import chunk from 'lodash-es/chunk'; 13 | import drop from 'lodash-es/drop'; 14 | import dropRight from 'lodash-es/dropRight'; 15 | import take from 'lodash-es/take'; 16 | import takeRight from 'lodash-es/takeRight'; 17 | import flattenDepth from 'lodash-es/flattenDepth'; 18 | import fromPairs from 'lodash-es/fromPairs'; 19 | import nth from 'lodash-es/nth'; 20 | import reverse from 'lodash-es/reverse'; 21 | import uniq from 'lodash-es/uniq'; 22 | import uniqBy from 'lodash-es/uniqBy'; 23 | import countBy from 'lodash-es/countBy'; 24 | import filter from 'lodash-es/filter'; 25 | import reject from 'lodash-es/reject'; 26 | import groupBy from 'lodash-es/groupBy'; 27 | import sortBy from 'lodash-es/sortBy'; 28 | import minBy from 'lodash-es/minBy'; 29 | import maxBy from 'lodash-es/maxBy'; 30 | import meanBy from 'lodash-es/meanBy'; 31 | import sumBy from 'lodash-es/sumBy'; 32 | import join from 'lodash-es/join'; 33 | 34 | import get from 'lodash-es/get'; 35 | import mapValues from 'lodash-es/mapValues'; 36 | import at from 'lodash-es/at'; 37 | import toPairs from 'lodash-es/toPairs'; 38 | import invert from 'lodash-es/invert'; 39 | import invertBy from 'lodash-es/invertBy'; 40 | import keys from 'lodash-es/keys'; 41 | import values from 'lodash-es/values'; 42 | 43 | const transformations = { 44 | Array: { 45 | each: (array, arg) => { 46 | return map(array, item => applyTransformations(item, arg)); 47 | }, 48 | map, 49 | keyBy, 50 | chunk, 51 | drop, 52 | dropRight, 53 | take, 54 | takeRight, 55 | flattenDepth, 56 | fromPairs, 57 | nth, 58 | reverse, 59 | uniq, 60 | uniqBy, 61 | countBy, 62 | filter, 63 | reject, 64 | filterIf: (array, arg) => { 65 | return filter(array, item => applyTransformations(item, arg)); 66 | }, 67 | rejectIf: (array, arg) => { 68 | return reject(array, item => applyTransformations(item, arg)); 69 | }, 70 | groupBy, 71 | sortBy, 72 | minBy, 73 | maxBy, 74 | meanBy, 75 | sumBy, 76 | join, 77 | }, 78 | Object: { 79 | get, 80 | mapValues, 81 | at, 82 | toPairs, 83 | invert, 84 | invertBy, 85 | keys, 86 | values, 87 | }, 88 | Number: { 89 | lt, 90 | lte, 91 | gt, 92 | gte, 93 | eq, 94 | }, 95 | String: { 96 | startsWith, 97 | endsWith, 98 | }, 99 | }; 100 | 101 | const opToExpectedType = {}; 102 | for (const type in transformations) 103 | for (const name in transformations[type]) 104 | opToExpectedType[name] = type; 105 | 106 | export function applyTransformations(object, args) { 107 | if (!args) 108 | return object; 109 | 110 | for (const op in args) { 111 | if (object === null) 112 | break; 113 | 114 | const arg = args[op]; 115 | 116 | if (op === 'and') { 117 | object = every(arg, predicateArgs => applyTransformations(object, predicateArgs)); 118 | continue; 119 | } 120 | if (op === 'or') { 121 | object = some(arg, predicateArgs => applyTransformations(object, predicateArgs)); 122 | continue; 123 | } 124 | 125 | const expectedType = opToExpectedType[op]; 126 | let type = object.constructor && object.constructor.name; 127 | // handle objects created with Object.create(null) 128 | if (!type && (typeof object === 'object')) 129 | type = 'Object'; 130 | 131 | if (expectedType !== type) 132 | throw Error(`"${op}" transformation expect "${expectedType}" but got "${type}"`); 133 | 134 | object = transformations[type][op](object, arg); 135 | } 136 | return object; 137 | } 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "module": "es2015", 6 | "target": "es5", 7 | "noImplicitAny": false, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "sourceMap": true, 11 | "declaration": true, 12 | "outDir": "lib", 13 | "pretty": true, 14 | "moduleResolution": "node", 15 | "lib": ["es2017"] 16 | }, 17 | "compileOnSave": false, 18 | "exclude": [ 19 | "demo", 20 | "node_modules", 21 | ".tmp", 22 | "lib" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | devtool: 'source-map', 5 | entry: ['./src/index.ts'], 6 | resolve: { 7 | extensions: ['.tsx', '.ts', '.mjs', '.js'], 8 | }, 9 | output: { 10 | path: path.join(__dirname, 'lib'), 11 | filename: 'graphql-lodash.bundle.js', 12 | sourceMapFilename: '[file].map', 13 | library: 'GQLLodash', 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | use: 'ts-loader', 22 | exclude: /node_modules/, 23 | } 24 | ] 25 | } 26 | } 27 | --------------------------------------------------------------------------------