blog.takurinton.dev

GraphQL の print と parse

2021-09-19

こんにちは

どうも、僕です。

最近、業務や趣味で GraphQL の AST や query を動的にいじるようなことをしていて、その中で print 関数や parse 関数を脳死で使っていたのですが、ふと中身がどうなっているのか気になったため、ちょっと調べてみました。

なお、今回は、AST の見方などは書きません。

print と parse とは

parse 関数

print 関数とは、GraphQL の query から AST を生成する関数で、以下のようになります。

import { print, parse } from 'graphql';

const query = `
query {
    data: hoge(name: "takurinton", age: 21) {
    name
    age
    genre
  }
}`;

const ast = parse(query);
console.log(ast);

このように書くと、以下のように出力されます。

{
  kind: 'Document',
  definitions: [
    {
      kind: 'OperationDefinition',
      operation: 'query',
      name: undefined,
      variableDefinitions: [],
      directives: [],
      selectionSet: [Object],
      loc: [Object]
    }
  ],
  loc: { start: 0, end: 89 }
}

print 関数

parse 関数はその逆で、AST から query を生成してくれます。

ちょっと長いですが、AST(type ASTNode)を全部展開してるのでしょうがないです。

import { print } from 'graphql';

const ast = {
	"kind": "Document",
	"definitions": [
		{
			"kind": "OperationDefinition",
			"operation": "query",
			"variableDefinitions": [],
			"directives": [],
			"selectionSet": {
				"kind": "SelectionSet",
				"selections": [
					{
						"kind": "Field",
						"alias": {
							"kind": "Name",
							"value": "data",
							"loc": {
								"start": 13,
								"end": 17
							}
						},
						"name": {
							"kind": "Name",
							"value": "hoge",
							"loc": {
								"start": 19,
								"end": 23
							}
						},
						"arguments": [
							{
								"kind": "Argument",
								"name": {
									"kind": "Name",
									"value": "name",
									"loc": {
										"start": 24,
										"end": 28
									}
								},
								"value": {
									"kind": "StringValue",
									"value": "takurinton",
									"block": false,
									"loc": {
										"start": 30,
										"end": 42
									}
								},
								"loc": {
									"start": 24,
									"end": 42
								}
							},
							{
								"kind": "Argument",
								"name": {
									"kind": "Name",
									"value": "age",
									"loc": {
										"start": 44,
										"end": 47
									}
								},
								"value": {
									"kind": "IntValue",
									"value": "21",
									"loc": {
										"start": 49,
										"end": 51
									}
								},
								"loc": {
									"start": 44,
									"end": 51
								}
							}
						],
						"directives": [],
						"selectionSet": {
							"kind": "SelectionSet",
							"selections": [
								{
									"kind": "Field",
									"name": {
										"kind": "Name",
										"value": "name",
										"loc": {
											"start": 59,
											"end": 63
										}
									},
									"arguments": [],
									"directives": [],
									"loc": {
										"start": 59,
										"end": 63
									}
								},
								{
									"kind": "Field",
									"name": {
										"kind": "Name",
										"value": "age",
										"loc": {
											"start": 68,
											"end": 71
										}
									},
									"arguments": [],
									"directives": [],
									"loc": {
										"start": 68,
										"end": 71
									}
								},
								{
									"kind": "Field",
									"name": {
										"kind": "Name",
										"value": "genre",
										"loc": {
											"start": 76,
											"end": 81
										}
									},
									"arguments": [],
									"directives": [],
									"loc": {
										"start": 76,
										"end": 81
									}
								}
							],
							"loc": {
								"start": 53,
								"end": 85
							}
						},
						"loc": {
							"start": 13,
							"end": 85
						}
					}
				],
				"loc": {
					"start": 7,
					"end": 87
				}
			},
			"loc": {
				"start": 1,
				"end": 87
			}
		}
	],
	"loc": {
		"start": 0,
		"end": 87
	}
}

const query = print(ast);
console.log(query);

このように書くと、以下のように出力されます。

{
  data: hoge(name: "takurinton", age: 21) {
    name
    age
    genre
  }
}

読んでみる

概要がわかったところで読んでいきます。

自分が最近やってるのは、これらの相互変換をするような内容で、ast と query の双方を監視し、それらの変更があったら print と parse の両方を実行してお互いの変更をお互いに検知するような仕組みを作っています。これは後日別記事として公開する予定です。

それでは、読んでいきます。

parse

ここ

parse 関数は Parser クラスのインスタンスを作成して、parseDocument 関数を実行しています。

const parser = new Parser(source, options);
return parser.parseDocument();

ここ ここ

中身を見てみます。

  /**
   * Document : Definition+
   */
  parseDocument(): DocumentNode {
    return this.node(this._lexer.token, {
      kind: Kind.DOCUMENT,
      definitions: this.many(
        TokenKind.SOF,
        this.parseDefinition,
        TokenKind.EOF,
      ),
    });
  }

まず、this._lexer.token ですが、AST の形式を定義してるクラスです。ここでの引数は、AST の形式をしています。?

lexer.ts lookahead 関数 readNextToken 関数

(ところで readNextToken やばそう...。)

createToken 関数は以下のようになっていて、line と loc を生成して、Token クラスに渡すことで生成をしています。

ここら辺は AST の仕様なので気になる人は見てみてください。

/**
 * Create a token with line and column location information.
 */
function createToken(
  lexer: Lexer,
  kind: TokenKindEnum,
  start: number,
  end: number,
  value?: string,
): Token {
  const line = lexer.line;
  const col = 1 + start - lexer.lineStart;
  return new Token(kind, start, end, line, col, value);
}

Token クラス

ここまでの関数は、parseDocument 関数に戻ってきて、それぞれの key をみて parse されて return されます。なるほど。

parseDefinition

print

次は print 関数です。

ここ

print 関数は、visit 関数を呼び出すだけになっています。visit 関数を見ていきます。

ここ

visit 関数は、深さ優先探索を行い、AST を展開します。また、上のデモでは渡していませんが、オプションとして Enter 関数と Leave 関数から異なる値を返すことにより非破壊的な変更を実現しています。

stack がなくなるまで、上から探索をしていきます。stack には、DocumentNode のデータが入っているので、Document、definetion、SelectionSet、、、の順で探索をし、そこから query を生成します。

edits というリストに、それぞれ読み込んだ結果に対して、key と node を加えていきます。

それぞれの Node に対してこれらの作業を繰り返すことで、query を生成していきます。

まとめ

普段何気なく使ってる関数を追ってみるのは楽しいなと感じました。

parse が多少重い実装になってるだろうなという想像はついたのですが、想像以上に大きなコードがあって驚いています。babel の parse 関数などもしっかり読んだことはないのですが、同じように実装されているんだろうなと思います。

まだまだ AST マスターへの道は長いです。