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 関数は [ここ](https://github.com/graphql/graphql-js/blob/e6820a98b27b0d0c0c880edfe3b5b39a72496a62/src/language/parser.ts#L105-L111) にあります。
parse 関数は Parser クラスのインスタンスを作成して、parseDocument 関数を実行しています。
const parser = new Parser(source, options);
return parser.parseDocument();
この Parser 関数というのは、[ここ](https://github.com/graphql/graphql-js/blob/e6820a98b27b0d0c0c880edfe3b5b39a72496a62/src/language/parser.ts#L181) で定義されています。
また、parseDocument 関数は [ここ](https://github.com/graphql/graphql-js/blob/e6820a98b27b0d0c0c880edfe3b5b39a72496a62/src/language/parser.ts#L208) で定義されています。
中身を見てみます。
/**
* 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 の形式をしています。?
また、definetions で呼ばれてる many 関数ですが、内部的に呼ばれてる関数は、[lexer.ts](https://github.com/graphql/graphql-js/blob/main/src/language/lexer.ts) で定義されています。
主に、[lookahead 関数](https://github.com/graphql/graphql-js/blob/main/src/language/lexer.ts#L59-L86) で定義されていて、ここで token を順番に呼び出して parse していきます。
readNextToken 関数の中で呼ばれている [createToken 関数](https://github.com/graphql/graphql-js/blob/e6820a98b27b0d0c0c880edfe3b5b39a72496a62/src/language/lexer.ts#L184-L197) で AST を生成しています。
(ところで 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 クラス](https://github.com/graphql/graphql-js/blob/e6820a98b27b0d0c0c880edfe3b5b39a72496a62/src/language/ast.ts#L55-L133) ですが、ここでは吐き出す形式を整えています。
ここまでの関数は、parseDocument 関数に戻ってきて、それぞれの key をみて parse されて return されます。なるほど。
many 関数の中で呼ばれている [parseDefinition](https://github.com/graphql/graphql-js/blob/main/src/language/parser.ts#L242) 関数では、schema や type、また query、mutation などの定義を展開していきます。ここらへんで定義してるんですね。理解。
次は print 関数です。
print 関数は [ここ](https://github.com/graphql/graphql-js/blob/e6820a98b27b0d0c0c880edfe3b5b39a72496a62/src/language/printer.ts#L13-L15) にあります。
print 関数は、visit 関数を呼び出すだけになっています。visit 関数を見ていきます。
visit 関数は [ここ](https://github.com/graphql/graphql-js/blob/main/src/language/visitor.ts#L245-L364) で定義されています。
visit 関数は、深さ優先探索を行い、AST を展開します。また、上のデモでは渡していませんが、オプションとして Enter 関数と Leave 関数から異なる値を返すことにより非破壊的な変更を実現しています。
stack がなくなるまで、上から探索をしていきます。stack には、DocumentNode のデータが入っているので、Document、definetion、SelectionSet、、、の順で探索をし、そこから query を生成します。
edits というリストに、それぞれ読み込んだ結果に対して、key と node を加えていきます。
それぞれの Node に対してこれらの作業を繰り返すことで、query を生成していきます。
まとめ
普段何気なく使ってる関数を追ってみるのは楽しいなと感じました。
parse が多少重い実装になってるだろうなという想像はついたのですが、想像以上に大きなコードがあって驚いています。babel の parse 関数などもしっかり読んだことはないのですが、同じように実装されているんだろうなと思います。
まだまだ AST マスターへの道は長いです。