├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── ast.md ├── package.json ├── src ├── graphql.js ├── index.js ├── parse.js ├── parser.js ├── tokenizer.js └── traverse.js └── test ├── graphql.js ├── mocha.opts ├── parse.js └── traverse.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all", 4 | "blacklist": [ 5 | "es6.tailCall", 6 | "spec.functionName" 7 | ], 8 | "optional": [ 9 | "runtime" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | 4 | "env": { 5 | "es6": true, 6 | "browser": true, 7 | "node": true, 8 | "mocha": true 9 | }, 10 | 11 | "rules": { 12 | "block-scoped-var": 0, 13 | "brace-style": [2, "1tbs", {"allowSingleLine": true}], 14 | "camelcase": 0, 15 | "comma-dangle": [2, "always-multiline"], 16 | "comma-style": [2, "last"], 17 | "consistent-this": [2, "self"], 18 | "curly": 0, 19 | "indent": [2, 2], 20 | "key-spacing": 0, 21 | "quotes": [2, "single", "avoid-escape"], 22 | "no-multiple-empty-lines": [2, {"max": 1}], 23 | "no-self-compare": 2, 24 | "no-underscore-dangle": 0, 25 | "no-unused-vars": [1, {"vars": "all", "args": "none"}], 26 | "no-use-before-define": 0, 27 | "no-var": 2, 28 | "semi": [2, "never"], 29 | "space-after-keywords": [2, "always"], 30 | "space-before-blocks": [2, "always"], 31 | "space-before-function-parentheses": [2, "never"], 32 | "space-in-parens": [2, "never"], 33 | "spaced-line-comment": [2, "always"], 34 | "strict": 0, 35 | "yoda": 0 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "iojs" 5 | script: npm run travis 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.1.0 4 | 5 | * Don't mutate AST nodes (#5) 6 | * Upgrade vendors 7 | 8 | ## 2.0.0 9 | 10 | * Update GraphQL syntax according to the latest preview 11 | * Rework AST ([specification][docs-ast]) 12 | * Rework all API 13 | * Replace `parsly` by a brand new parser 14 | * Replace `graphql-types` by plain JS objects 15 | 16 | ## 1.3.0 17 | 18 | * Expose `concat` helper 19 | 20 | ## 1.2.0 21 | 22 | * Add AST generation 23 | * Add AST transformation API 24 | * Add `traverse` utility 25 | * Remove inconsistencies of argument parsing algorithm 26 | 27 | ## 1.1.1 28 | 29 | * Upgrade `graphql-types` 30 | 31 | ## 1.1.0 32 | 33 | * Move type definitions to `graphql-types` 34 | 35 | ## 1.0.1 36 | 37 | * Publish on NPM 38 | * Upgrade vendors 39 | 40 | ## 1.0.0 41 | 42 | * Initial release 43 | 44 | [docs-ast]: docs/ast.md 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Florent Cailhol 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-parser 2 | 3 | > Experimental Facebook's GraphQL parser 4 | 5 | This parser is inspired by Facebook's articles about GraphQL. It supports the latest shown syntax. 6 | 7 | * [Introducing Relay and GraphQL][fb-1] 8 | * [Building The Facebook News Feed With Relay][fb-2] 9 | * [GraphQL Introduction][fb-3] (current implementation) 10 | 11 | ## Install 12 | 13 | ```sh 14 | npm install --save graphql-parser 15 | ``` 16 | 17 | ## Usage 18 | 19 | `graphql-parser` exposes a tagged template function for parsing GraphQL queries. It outputs a function generating a JS object describing the query. 20 | 21 | ```js 22 | import graphql from 'graphql-parser' 23 | 24 | const IMAGE_WIDTH = 80 25 | const IMAGE_HEIGHT = 80 26 | 27 | // Compile a fragment 28 | const PostFragment = graphql` 29 | { 30 | id, 31 | title, 32 | published_at 33 | } 34 | ` 35 | 36 | // Compile a query 37 | const UserQuery = graphql` 38 | { 39 | user(id: ) { 40 | id, 41 | nickname, 42 | avatar(width: ${IMAGE_WIDTH}, height: ${IMAGE_HEIGHT}) { 43 | url(protocol: "https") 44 | }, 45 | posts(first: ) { 46 | count, 47 | edges { 48 | node as post { 49 | ${ PostFragment() } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | ` 56 | 57 | // Generate a GraphQL query 58 | const query = UserQuery({ 59 | id: 1337, 60 | count: 10, 61 | }) 62 | ``` 63 | 64 | In the above example output will be: 65 | 66 | ```js 67 | { 68 | "user": { 69 | "params": { 70 | "id": 1337 71 | }, 72 | "fields": { 73 | "id": {}, 74 | "nickname": {}, 75 | "avatar": { 76 | "params": { 77 | "width": 80, 78 | "height": 80 79 | }, 80 | "fields": { 81 | "url": { 82 | "params": { 83 | "protocol": "https" 84 | } 85 | } 86 | } 87 | }, 88 | "posts": { 89 | "params": { 90 | "first": 10 91 | }, 92 | "fields": { 93 | "count": {}, 94 | "edges": { 95 | "fields": { 96 | "node": { 97 | "alias": "post", 98 | "fields": { 99 | "id": {}, 100 | "title": {}, 101 | "published_at": {} 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | ## AST manipulation 114 | 115 | `graphql-parser` also exposes lower level API for generating [GraphQL AST][docs-ast] and traversing it. 116 | 117 | ```js 118 | import { parse, traverse } from 'graphql-parser' 119 | 120 | // Parsing 121 | // ------- 122 | 123 | const ast = parse(` 124 | { 125 | post(slug: ) { 126 | id, 127 | title, 128 | image(width: 50, height: 50) as cover { 129 | url 130 | } 131 | } 132 | } 133 | `) 134 | 135 | // Traversal 136 | // --------- 137 | 138 | const ctx = { 139 | slug: "/graphql-is-hot" 140 | } 141 | 142 | const obj = traverse(ast, { 143 | // Lookup variables 144 | Variable(node) { 145 | return ctx[node.name] 146 | } 147 | }) 148 | 149 | ``` 150 | 151 | [docs-ast]: docs/ast.md 152 | [fb-1]: https://facebook.github.io/react/blog/2015/02/20/introducing-relay-and-graphql.html 153 | [fb-2]: https://facebook.github.io/react/blog/2015/03/19/building-the-facebook-news-feed-with-relay.html 154 | [fb-3]: https://facebook.github.io/react/blog/2015/05/01/graphql-introduction.html 155 | -------------------------------------------------------------------------------- /docs/ast.md: -------------------------------------------------------------------------------- 1 | This document specifies the core AST node types that support the GraphQL grammar. 2 | 3 | # Node object 4 | 5 | AST nodes are represented as `Node` objects, which may have any prototype inheritance but which implement the following interface: 6 | 7 | ```js 8 | interface Node { 9 | type: string; 10 | } 11 | ``` 12 | 13 | The `type` field is a string representing the AST variant type. Each subtype of `Node` is documented below with the specific string of its type field. You can use this field to determine which interface a node implements. 14 | 15 | # Query 16 | 17 | ```js 18 | interface Query <: Node { 19 | type: "Query"; 20 | fields: [ Field ]; 21 | } 22 | ``` 23 | 24 | A query source tree. It may also describe field fragments. 25 | 26 | # Field 27 | 28 | ```js 29 | interface Field <: Node { 30 | type: "Field"; 31 | name: string; 32 | params: [ Argument ]; 33 | fields: [ Field ]; 34 | } 35 | ``` 36 | 37 | A field declaration. 38 | 39 | # Argument 40 | 41 | ```js 42 | interface Argument <: Node { 43 | type: "Argument"; 44 | name: string; 45 | value: [ Literal | Variable | Reference ]; 46 | } 47 | ``` 48 | 49 | A named argument. 50 | 51 | # Literal 52 | 53 | ```js 54 | interface Literal <: Node { 55 | type: "Literal"; 56 | value: string | boolean | number | null; 57 | } 58 | ``` 59 | 60 | A JSON literal token. See [ECMA-404][ecma-404] specification. 61 | 62 | # Variable 63 | 64 | ```js 65 | interface Variable <: Node { 66 | type: "Variable"; 67 | name: string; 68 | } 69 | ``` 70 | 71 | A query variable. 72 | 73 | # Reference 74 | 75 | ```js 76 | interface Reference <: Node { 77 | type: "Reference"; 78 | name: string; 79 | } 80 | ``` 81 | 82 | A reference to a context value. Used for internal usage. 83 | 84 | [ecma-404]: http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-parser", 3 | "version": "2.0.0", 4 | "description": "Experimental Facebook's GraphQL parser", 5 | "author": "Florent Cailhol ", 6 | "license": "MIT", 7 | "repository": "ooflorent/graphql-parser", 8 | "keywords": [ 9 | "graphql", 10 | "parser" 11 | ], 12 | "main": "lib/index.js", 13 | "files": [ 14 | "lib" 15 | ], 16 | "dependencies": { 17 | "babel-runtime": "^5.6.15" 18 | }, 19 | "devDependencies": { 20 | "babel": "^5.6.14", 21 | "babel-eslint": "^3.1.19", 22 | "eslint": "^0.24.0", 23 | "lodash": "^3.9.1", 24 | "mocha": "^2.2.5" 25 | }, 26 | "scripts": { 27 | "clean": "rm -rf lib", 28 | "build": "babel src --out-dir lib --copy-files", 29 | "watch": "npm run build -- --watch", 30 | "lint": "eslint src/", 31 | "test": "mocha", 32 | "prepublish": "npm run clean && npm run build" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/graphql.js: -------------------------------------------------------------------------------- 1 | import parse from './parse' 2 | import traverse from './traverse' 3 | 4 | function join(parts) { 5 | let result = parts[0] || '' 6 | for (let i = 1; i < parts.length; i++) { 7 | result += '&' + (i - 1) + parts[i] 8 | } 9 | 10 | return result 11 | } 12 | 13 | export default function graphql(strings, ...values) { 14 | const source = join(strings) 15 | const ast = parse(source) 16 | 17 | return (params) => traverse(ast, new TaggedTemplateVisitor(params, values)) 18 | } 19 | 20 | const rootSymbol = '@@root' 21 | const nameSymbol = '@@name' 22 | 23 | class TaggedTemplateVisitor { 24 | constructor(params, quasis) { 25 | this.params = params 26 | this.quasis = quasis 27 | } 28 | 29 | transformFields(fields) { 30 | const obj = {} 31 | 32 | for (let i = 0; i < fields.length; i++) { 33 | const f = fields[i] 34 | if (f.hasOwnProperty(rootSymbol)) { 35 | for (let field in f) { 36 | obj[field] = f[field] 37 | } 38 | } else { 39 | obj[f[nameSymbol]] = f 40 | } 41 | } 42 | 43 | return obj 44 | } 45 | 46 | Query(node) { 47 | const query = this.transformFields(node.fields) 48 | Object.defineProperty(query, rootSymbol, {value: true}) 49 | 50 | return query 51 | } 52 | 53 | Field(node) { 54 | const field = {} 55 | Object.defineProperty(field, nameSymbol, {value: node.name}) 56 | 57 | if (node.alias) { 58 | field.alias = node.alias 59 | } 60 | 61 | if (node.params.length > 0) { 62 | field.params = {} 63 | 64 | for (let i = 0; i < node.params.length; i++) { 65 | const arg = node.params[i] 66 | field.params[arg.name] = arg.value 67 | } 68 | } 69 | 70 | if (node.fields.length > 0) { 71 | field.fields = this.transformFields(node.fields) 72 | } 73 | 74 | return field 75 | } 76 | 77 | Literal(node) { 78 | return node.value 79 | } 80 | 81 | Reference(node) { 82 | return this.quasis[node.name] 83 | } 84 | 85 | Variable(node) { 86 | return this.params[node.name] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import graphql from './graphql' 2 | import parse from './parse' 3 | import traverse from './traverse' 4 | 5 | export { parse, traverse } 6 | export default graphql 7 | -------------------------------------------------------------------------------- /src/parse.js: -------------------------------------------------------------------------------- 1 | import Parser from './parser' 2 | 3 | export default function parse(source) { 4 | const parser = new Parser(source) 5 | return parser.parseQuery() 6 | } 7 | -------------------------------------------------------------------------------- /src/parser.js: -------------------------------------------------------------------------------- 1 | import Tokenizer, { TokenType } from './tokenizer' 2 | 3 | export default class Parser extends Tokenizer { 4 | match(type) { 5 | return this.lookahead.type === type 6 | } 7 | 8 | eat(type) { 9 | if (this.match(type)) { 10 | return this.lex() 11 | } 12 | 13 | return null 14 | } 15 | 16 | expect(type) { 17 | if (this.match(type)) { 18 | return this.lex() 19 | } 20 | 21 | throw this.createUnexpected(this.lookahead) 22 | } 23 | 24 | parseQuery() { 25 | return { type: 'Query', fields: this.parseFieldList() } 26 | } 27 | 28 | parseIdentifier() { 29 | return this.expect(TokenType.IDENTIFIER).value 30 | } 31 | 32 | parseFieldList() { 33 | this.expect(TokenType.LBRACE) 34 | 35 | const fields = [] 36 | let first = true 37 | 38 | while (!this.match(TokenType.RBRACE) && !this.end()) { 39 | if (first) { 40 | first = false 41 | } else { 42 | this.expect(TokenType.COMMA) 43 | } 44 | 45 | if (this.match(TokenType.AMP)) { 46 | fields.push(this.parseReference()) 47 | } else { 48 | fields.push(this.parseField()) 49 | } 50 | } 51 | 52 | this.expect(TokenType.RBRACE) 53 | return fields 54 | } 55 | 56 | parseField() { 57 | const name = this.parseIdentifier() 58 | const params = this.match(TokenType.LPAREN) ? this.parseArgumentList() : [] 59 | const alias = this.eat(TokenType.AS) ? this.parseIdentifier() : null 60 | const fields = this.match(TokenType.LBRACE) ? this.parseFieldList() : [] 61 | 62 | return { type: 'Field', name, alias, params, fields } 63 | } 64 | 65 | parseArgumentList() { 66 | const args = [] 67 | let first = true 68 | 69 | this.expect(TokenType.LPAREN) 70 | 71 | while (!this.match(TokenType.RPAREN) && !this.end()) { 72 | if (first) { 73 | first = false 74 | } else { 75 | this.expect(TokenType.COMMA) 76 | } 77 | 78 | args.push(this.parseArgument()) 79 | } 80 | 81 | this.expect(TokenType.RPAREN) 82 | return args 83 | } 84 | 85 | parseArgument() { 86 | const name = this.parseIdentifier() 87 | this.expect(TokenType.COLON) 88 | const value = this.parseValue() 89 | 90 | return { type: 'Argument', name, value } 91 | } 92 | 93 | parseValue() { 94 | switch (this.lookahead.type) { 95 | case TokenType.AMP: 96 | return this.parseReference() 97 | 98 | case TokenType.LT: 99 | return this.parseVariable() 100 | 101 | case TokenType.NUMBER: 102 | case TokenType.STRING: 103 | return { type: 'Literal', value: this.lex().value } 104 | 105 | case TokenType.NULL: 106 | case TokenType.TRUE: 107 | case TokenType.FALSE: 108 | return { type: 'Literal', value: JSON.parse(this.lex().value) } 109 | } 110 | 111 | throw this.createUnexpected(this.lookahead) 112 | } 113 | 114 | parseReference() { 115 | this.expect(TokenType.AMP) 116 | 117 | if (this.match(TokenType.NUMBER) || this.match(TokenType.IDENTIFIER)) { 118 | return { type: 'Reference', name: this.lex().value } 119 | } 120 | 121 | throw this.createUnexpected(this.lookahead) 122 | } 123 | 124 | parseVariable() { 125 | this.expect(TokenType.LT) 126 | const name = this.expect(TokenType.IDENTIFIER).value 127 | this.expect(TokenType.GT) 128 | 129 | return { type: 'Variable', name } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/tokenizer.js: -------------------------------------------------------------------------------- 1 | export const TokenClass = { 2 | End: {name: 'End'}, 3 | Punctuator: {name: 'Punctuator'}, 4 | Keyword: {name: 'Keyword'}, 5 | Identifier: {name: 'Identifier'}, 6 | NumberLiteral: {name: 'Number'}, 7 | StringLiteral: {name: 'String'}, 8 | } 9 | 10 | export const TokenType = { 11 | // Special 12 | END: {klass: TokenClass.End, name: 'end'}, 13 | IDENTIFIER: {klass: TokenClass.Identifier, name: 'identifier'}, 14 | NUMBER: {klass: TokenClass.NumberLiteral, name: 'number'}, 15 | STRING: {klass: TokenClass.StringLiteral, name: 'string'}, 16 | 17 | // Punctuators 18 | LT: {klass: TokenClass.Punctuator, name: '<'}, 19 | GT: {klass: TokenClass.Punctuator, name: '>'}, 20 | LBRACE: {klass: TokenClass.Punctuator, name: '{'}, 21 | RBRACE: {klass: TokenClass.Punctuator, name: '}'}, 22 | LPAREN: {klass: TokenClass.Punctuator, name: '('}, 23 | RPAREN: {klass: TokenClass.Punctuator, name: ')'}, 24 | COLON: {klass: TokenClass.Punctuator, name: ':'}, 25 | COMMA: {klass: TokenClass.Punctuator, name: ','}, 26 | AMP: {klass: TokenClass.Punctuator, name: '&'}, 27 | 28 | // Keywords 29 | NULL: {klass: TokenClass.Keyword, name: 'null'}, 30 | TRUE: {klass: TokenClass.Keyword, name: 'true'}, 31 | FALSE: {klass: TokenClass.Keyword, name: 'false'}, 32 | AS: {klass: TokenClass.Keyword, name: 'as'}, 33 | } 34 | 35 | export default class Tokenizer { 36 | constructor(source) { 37 | this.source = source 38 | this.pos = 0 39 | this.line = 1 40 | this.lineStart = 0 41 | this.lookahead = this.next() 42 | } 43 | 44 | get column() { 45 | return this.pos - this.lineStart 46 | } 47 | 48 | getKeyword(name) { 49 | switch (name) { 50 | case 'null': return TokenType.NULL 51 | case 'true': return TokenType.TRUE 52 | case 'false': return TokenType.FALSE 53 | case 'as': return TokenType.AS 54 | } 55 | 56 | return TokenType.IDENTIFIER 57 | } 58 | 59 | end() { 60 | return this.lookahead.type === TokenType.END 61 | } 62 | 63 | peek() { 64 | return this.lookahead 65 | } 66 | 67 | lex() { 68 | const prev = this.lookahead 69 | this.lookahead = this.next() 70 | return prev 71 | } 72 | 73 | next() { 74 | this.skipWhitespace() 75 | 76 | const line = this.line 77 | const lineStart = this.lineStart 78 | const token = this.scan() 79 | 80 | token.line = line 81 | token.column = this.pos - lineStart 82 | 83 | return token 84 | } 85 | 86 | scan() { 87 | if (this.pos >= this.source.length) { 88 | return { type: TokenType.END } 89 | } 90 | 91 | const ch = this.source.charAt(this.pos) 92 | switch (ch) { 93 | case '(': ++this.pos; return { type: TokenType.LPAREN } 94 | case ')': ++this.pos; return { type: TokenType.RPAREN } 95 | case '{': ++this.pos; return { type: TokenType.LBRACE } 96 | case '}': ++this.pos; return { type: TokenType.RBRACE } 97 | case '<': ++this.pos; return { type: TokenType.LT } 98 | case '>': ++this.pos; return { type: TokenType.GT } 99 | case '&': ++this.pos; return { type: TokenType.AMP } 100 | case ',': ++this.pos; return { type: TokenType.COMMA } 101 | case ':': ++this.pos; return { type: TokenType.COLON } 102 | } 103 | 104 | if (ch === '_' || ch === '$' || 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z') { 105 | return this.scanWord() 106 | } 107 | 108 | if (ch === '-' || '0' <= ch && ch <= '9') { 109 | return this.scanNumber() 110 | } 111 | 112 | if (ch === '"') { 113 | return this.scanString() 114 | } 115 | 116 | throw this.createIllegal() 117 | } 118 | 119 | scanPunctuator() { 120 | const glyph = this.source.charAt(this.pos++) 121 | return { type: glyph } 122 | } 123 | 124 | scanWord() { 125 | const start = this.pos 126 | this.pos++ 127 | 128 | while (this.pos < this.source.length) { 129 | let ch = this.source.charAt(this.pos) 130 | if (ch === '_' || ch === '$' || 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || '0' <= ch && ch <= '9') { 131 | this.pos++ 132 | } else { 133 | break 134 | } 135 | } 136 | 137 | const value = this.source.slice(start, this.pos) 138 | return { type: this.getKeyword(value), value } 139 | } 140 | 141 | scanNumber() { 142 | const start = this.pos 143 | 144 | if (this.source.charAt(this.pos) === '-') { 145 | this.pos++ 146 | } 147 | 148 | this.skipInteger() 149 | 150 | if (this.source.charAt(this.pos) === '.') { 151 | this.pos++ 152 | this.skipInteger() 153 | } 154 | 155 | let ch = this.source.charAt(this.pos) 156 | if (ch === 'e' || ch === 'E') { 157 | this.pos++ 158 | 159 | ch = this.source.charAt(this.pos) 160 | if (ch === '+' || ch === '-') { 161 | this.pos++ 162 | } 163 | 164 | this.skipInteger() 165 | } 166 | 167 | const value = parseFloat(this.source.slice(start, this.pos)) 168 | return { type: TokenType.NUMBER, value } 169 | } 170 | 171 | scanString() { 172 | this.pos++ 173 | 174 | let value = '' 175 | while (this.pos < this.source.length) { 176 | let ch = this.source.charAt(this.pos) 177 | if (ch === '"') { 178 | this.pos++ 179 | return { type: TokenType.STRING, value } 180 | } 181 | 182 | if (ch === '\r' || ch === '\n') { 183 | break 184 | } 185 | 186 | value += ch 187 | this.pos++ 188 | } 189 | 190 | throw this.createIllegal() 191 | } 192 | 193 | skipInteger() { 194 | const start = this.pos 195 | 196 | while (this.pos < this.source.length) { 197 | let ch = this.source.charAt(this.pos) 198 | if ('0' <= ch && ch <= '9') { 199 | this.pos++ 200 | } else { 201 | break 202 | } 203 | } 204 | 205 | if (this.pos - start === 0) { 206 | throw this.createIllegal() 207 | } 208 | } 209 | 210 | skipWhitespace() { 211 | while (this.pos < this.source.length) { 212 | let ch = this.source.charAt(this.pos) 213 | if (ch === ' ' || ch === '\t') { 214 | this.pos++ 215 | } else if (ch === '\r') { 216 | this.pos++ 217 | if (this.source.charAt(this.pos) === '\n') { 218 | this.pos++ 219 | } 220 | this.line++ 221 | this.lineStart = this.pos 222 | } else if (ch === '\n') { 223 | this.pos++ 224 | this.line++ 225 | this.lineStart = this.pos 226 | } else { 227 | break 228 | } 229 | } 230 | } 231 | 232 | createError(message) { 233 | return new SyntaxError(message + ` (${this.line}:${this.column})`) 234 | } 235 | 236 | createIllegal() { 237 | return this.pos < this.source.length 238 | ? this.createError(`Unexpected ${this.source.charAt(this.pos)}`) 239 | : this.createError('Unexpected end of input') 240 | } 241 | 242 | createUnexpected(token) { 243 | switch (token.type.klass) { 244 | case TokenClass.End: return this.createError('Unexpected end of input') 245 | case TokenClass.NumberLiteral: return this.createError('Unexpected number') 246 | case TokenClass.StringLiteral: return this.createError('Unexpected string') 247 | case TokenClass.Identifier: return this.createError('Unexpected identifier') 248 | case TokenClass.Keyword: return this.createError(`Unexpected token ${token.value}`) 249 | case TokenClass.Punctuator: return this.createError(`Unexpected token ${token.type.name}`) 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/traverse.js: -------------------------------------------------------------------------------- 1 | export default function traverse(node, visitor, parent) { 2 | if (!node) { 3 | return node 4 | } 5 | 6 | // Duplicate node to avoid mutations 7 | node = { ...node } 8 | 9 | const type = node.type 10 | switch (type) { 11 | case 'Query': 12 | node.fields = node.fields.map((n) => traverse(n, visitor, node)) 13 | break 14 | 15 | case 'Field': 16 | node.params = node.params.map((n) => traverse(n, visitor, node)) 17 | node.fields = node.fields.map((n) => traverse(n, visitor, node)) 18 | break 19 | 20 | case 'Argument': 21 | node.value = traverse(node.value, visitor, node) 22 | break 23 | } 24 | 25 | if (typeof visitor[type] === 'function') { 26 | const repl = visitor[type](node, parent) 27 | if (repl !== void 0) { 28 | node = repl 29 | } 30 | } 31 | 32 | return node 33 | } 34 | -------------------------------------------------------------------------------- /test/graphql.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import graphql from '../src/graphql' 3 | 4 | describe('graphql', () => { 5 | it('parses queries', () => { 6 | const query = graphql` 7 | { 8 | a(b: true, c: ) as e { 9 | f { g, h }, 10 | i(j: 50) 11 | }, 12 | k(l: null) { 13 | m, n 14 | } 15 | } 16 | ` 17 | 18 | assert.deepEqual(query({d: 42}), { 19 | a: { 20 | alias: 'e', 21 | params: { 22 | b: true, 23 | c: 42 24 | }, 25 | fields: { 26 | f: { 27 | fields: { 28 | g: {}, 29 | h: {} 30 | } 31 | }, 32 | i: { 33 | params: { 34 | j: 50 35 | } 36 | } 37 | } 38 | }, 39 | k: { 40 | params: { 41 | l: null 42 | }, 43 | fields: { 44 | m: {}, 45 | n: {} 46 | } 47 | } 48 | }) 49 | }) 50 | 51 | it('parses query fragments', () => { 52 | const queryA = graphql` 53 | { 54 | a, 55 | b(c: ${ "d" }) 56 | } 57 | ` 58 | 59 | const queryB = graphql` 60 | { 61 | ${ queryA() }, 62 | e, 63 | f 64 | } 65 | ` 66 | 67 | assert.deepEqual(queryB(), { 68 | a: {}, 69 | b: { 70 | params: { 71 | c: 'd' 72 | } 73 | }, 74 | e: {}, 75 | f: {} 76 | }) 77 | }) 78 | 79 | it('injects parameters', () => { 80 | const query = graphql` 81 | { 82 | user(id: ) 83 | } 84 | ` 85 | 86 | const user1 = query({ id: 1 }) 87 | const user2 = query({ id: 2 }) 88 | 89 | assert.equal(user1.user.params.id, 1) 90 | assert.equal(user2.user.params.id, 2) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel/register 2 | --reporter spec 3 | -------------------------------------------------------------------------------- /test/parse.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import parse from '../src/parse' 3 | 4 | function run(query, expected) { 5 | it(query, () => { 6 | assert.deepEqual(parse(query), expected) 7 | }) 8 | } 9 | 10 | describe('parse', () => { 11 | describe('fields', () => { 12 | // Nesting 13 | run('{ a, b { c { d } } }', { 14 | type: 'Query', 15 | fields: [ 16 | { 17 | type: 'Field', 18 | name: 'a', 19 | alias: null, 20 | params: [], 21 | fields: [] 22 | }, 23 | { 24 | type: 'Field', 25 | name: 'b', 26 | alias: null, 27 | params: [], 28 | fields: [ 29 | { 30 | type: 'Field', 31 | name: 'c', 32 | alias: null, 33 | params: [], 34 | fields: [ 35 | { 36 | type: 'Field', 37 | name: 'd', 38 | alias: null, 39 | params: [], 40 | fields: [] 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | ] 47 | }) 48 | 49 | // Aliases 50 | run('{ a as b, c(d: 1) as e { f } }', { 51 | type: 'Query', 52 | fields: [ 53 | { 54 | type: 'Field', 55 | name: 'a', 56 | alias: 'b', 57 | params: [], 58 | fields: [] 59 | }, 60 | { 61 | type: 'Field', 62 | name: 'c', 63 | alias: 'e', 64 | params: [ 65 | { 66 | type: 'Argument', 67 | name: 'd', 68 | value: { 69 | type: 'Literal', 70 | value: 1 71 | } 72 | } 73 | ], 74 | fields: [ 75 | { 76 | type: 'Field', 77 | name: 'f', 78 | alias: null, 79 | params: [], 80 | fields: [] 81 | } 82 | ] 83 | } 84 | ] 85 | }) 86 | }) 87 | 88 | describe('arguments', () => { 89 | // Booleans & null 90 | run('{ a(b: true, c: false, d: null) }', { 91 | type: 'Query', 92 | fields: [ 93 | { 94 | type: 'Field', 95 | name: 'a', 96 | alias: null, 97 | params: [ 98 | { 99 | type: 'Argument', 100 | name: 'b', 101 | value: { 102 | type: 'Literal', 103 | value: true 104 | } 105 | }, 106 | { 107 | type: 'Argument', 108 | name: 'c', 109 | value: { 110 | type: 'Literal', 111 | value: false 112 | } 113 | }, 114 | { 115 | type: 'Argument', 116 | name: 'd', 117 | value: { 118 | type: 'Literal', 119 | value: null 120 | } 121 | } 122 | ], 123 | fields: [] 124 | } 125 | ] 126 | }) 127 | 128 | // Numbers 129 | run('{ a(b: 1, c: -12.34, d: 23.9E+6) }', { 130 | type: 'Query', 131 | fields: [ 132 | { 133 | type: 'Field', 134 | name: 'a', 135 | alias: null, 136 | params: [ 137 | { 138 | type: 'Argument', 139 | name: 'b', 140 | value: { 141 | type: 'Literal', 142 | value: 1 143 | } 144 | }, 145 | { 146 | type: 'Argument', 147 | name: 'c', 148 | value: { 149 | type: 'Literal', 150 | value: -12.34 151 | } 152 | }, 153 | { 154 | type: 'Argument', 155 | name: 'd', 156 | value: { 157 | type: 'Literal', 158 | value: 23900000 159 | } 160 | } 161 | ], 162 | fields: [] 163 | } 164 | ] 165 | }) 166 | 167 | // Strings 168 | run('{ a(b: "test", c: "") }', { 169 | type: 'Query', 170 | fields: [ 171 | { 172 | type: 'Field', 173 | name: 'a', 174 | alias: null, 175 | params: [ 176 | { 177 | type: 'Argument', 178 | name: 'b', 179 | value: { 180 | type: 'Literal', 181 | value: 'test' 182 | } 183 | }, 184 | { 185 | type: 'Argument', 186 | name: 'c', 187 | value: { 188 | type: 'Literal', 189 | value: '' 190 | } 191 | } 192 | ], 193 | fields: [] 194 | } 195 | ] 196 | }) 197 | 198 | // Variables & references 199 | run('{ a(b: , d: &e) }', { 200 | type: 'Query', 201 | fields: [ 202 | { 203 | type: 'Field', 204 | name: 'a', 205 | alias: null, 206 | params: [ 207 | { 208 | type: 'Argument', 209 | name: 'b', 210 | value: { 211 | type: 'Variable', 212 | name: 'c' 213 | } 214 | }, 215 | { 216 | type: 'Argument', 217 | name: 'd', 218 | value: { 219 | type: 'Reference', 220 | name: 'e' 221 | } 222 | } 223 | ], 224 | fields: [] 225 | } 226 | ] 227 | }) 228 | }) 229 | }) 230 | -------------------------------------------------------------------------------- /test/traverse.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import cloneDeep from 'lodash/lang/cloneDeep' 3 | import traverse from '../src/traverse' 4 | 5 | const ast = { 6 | type: 'Query', 7 | fields: [ 8 | { 9 | type: 'Field', 10 | name: 'a', 11 | alias: null, 12 | params: [ 13 | { 14 | type: 'Argument', 15 | name: 'b', 16 | value: { 17 | type: 'Variable', 18 | name: 'c' 19 | } 20 | } 21 | ], 22 | fields: [ 23 | { 24 | type: 'Field', 25 | name: 'd', 26 | alias: null, 27 | params: [], 28 | fields: [] 29 | }, 30 | { 31 | type: 'Field', 32 | name: 'e', 33 | alias: null, 34 | params: [ 35 | { 36 | type: 'Argument', 37 | name: 'f', 38 | value: { 39 | type: 'Literal', 40 | value: 30 41 | } 42 | } 43 | ], 44 | fields: [ 45 | { 46 | type: 'Field', 47 | name: 'g', 48 | alias: null, 49 | params: [], 50 | fields: [] 51 | }, 52 | { 53 | type: 'Field', 54 | name: 'h', 55 | alias: null, 56 | params: [], 57 | fields: [] 58 | } 59 | ] 60 | } 61 | ] 62 | }, 63 | { 64 | type: 'Field', 65 | name: 'i', 66 | alias: null, 67 | params: [], 68 | fields: [] 69 | } 70 | ] 71 | } 72 | 73 | describe('traverse', () => { 74 | it('does not traverse falsy nodes', () => { 75 | traverse(null, {}) 76 | }) 77 | 78 | it('passes through all fields', () => { 79 | const fields = [] 80 | const args = [] 81 | 82 | traverse(cloneDeep(ast), { 83 | Field(node) { 84 | fields.push(node.name) 85 | }, 86 | Argument(node) { 87 | args.push(node.name) 88 | } 89 | }) 90 | 91 | assert.deepEqual(fields, ['d', 'g', 'h', 'e', 'a', 'i']) 92 | assert.deepEqual(args, ['b', 'f']) 93 | }) 94 | 95 | it('replaces nodes', () => { 96 | const actual = traverse(cloneDeep(ast), { 97 | Argument(node) { 98 | node.name = node.name.toUpperCase() 99 | }, 100 | Literal(node) { 101 | return node.value 102 | }, 103 | }) 104 | 105 | assert.equal(actual.fields[0].params[0].name, 'B') 106 | assert.equal(actual.fields[0].fields[1].params[0].name, 'F') 107 | assert.equal(actual.fields[0].fields[1].params[0].value, 30) 108 | }) 109 | }) 110 | --------------------------------------------------------------------------------