blog.takurinton.dev

GraphQL の parse エディタ

2021-09-20

こんにちは

どうも、僕です。
シルバーウィークなのでこれまで書きたくても時間がなくて書けなかった記事をどんどん投下しています。
今回は、最近作ってる GraphQL の DocumentNode を parse して作ったエディタから動的に query を生成する画面のプロトコーディングをしたのでそれを簡単にまとめます。

概要

このツイートの感じです。 https://twitter.com/takurinton/status/1439255940193140739

左側に引数のエディタがあって、右側では動的にリアルタイムで query を生成しています。
GraphQL の AST を監視して(将来的に双方からいじれるようにしたいので監視自体は両方してる)、その変更を query に反映するということをしています。
また、状態の管理は Preact の Context API で行っており、コンポーネントは再帰的に呼び出して使っています。コードはめちゃくちゃ汚いです。正解がわからなかったので。
リポジトリは takurinton/editor にあります。PR とスターください。
また、今回は DocumentNode の ArgmentsNode の変更のみを許可しています。他の部分はいじれないようにしてあります。query 側から変更することはできませんが、できたとしても今はエラーは吐かないです。

使用した技術

  • preact
  • vite
  • graphql

定義から

まず、最初に元となる query と、query から 生成した AST を定義します。

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

const initialAst = parse(initialQuery);

App.tsx

次に、App コンポーネントの中身を見ます。
ここでは左側のエディタと右側のプレビューを flex で並べています。
それぞれのコンポーネントは、AST と query をもちます。これらの値は後から出てくる Context で管理をされています。
また、AST の管理は onChange 関数も持たせて、初期値と更新を定義しています。

export function App() {
  const [query, setQuery] = useState<string>(initialQuery); 
  const [ast, setAst] = useState<ASTNode>(initialAst);

  useEffect(() => {
    const ast = parse(query); // DocumentNode
    setAst(ast);
  }, []);

  return (
    <div style={
      {
        display: 'flex', 
        margin: 'auto', 
        width: '80vw',
      }
    }>
      <Editor 
        query={query} 
        ast={ast} 
        onChange={
          (query, ast) => {
            setQuery(query);
            setAst(ast);
          }
        }
        />
      <Preview 
        query={query} 
        ast={ast} 
      />
    </div>
  )
}

context.tsx

ここでは、AST と query の全体の管理をしています。
NodeContext で Context を生成し、useNodeContext という hooks を使用して状態を管理します。
引数には、children、root、onChangeNode 関数をとり、children は Providor で囲うコンポーネント、root は現在の AST、onChangeNode は変更を検知して反映するための関数になっています。

また、updateNode 関数で状態の更新を、getNode 関数で、どこからでも AST を参照することができるようになっています。

import { ASTNode, print } from 'graphql';
import { createContext, JSX } from 'preact';
import { useContext } from 'preact/hooks';

type NodeContextType = {
  updateNode(node: ASTNode, newNode: ASTNode): void;
  debug(): void;
  getNode(): ASTNode;
};

const NodeContext = createContext<NodeContextType>(null as any);

export function useNodeContext() {
  return useContext(NodeContext);
}

export function NodeContextProvider({
  children,
  root,
  onChangeNode,
}: {
  children: JSX.Element;
  root: ASTNode;
  onChangeNode: (root: ASTNode) => void;
}) {
  const api: NodeContextType = {
    updateNode(node, newNode) {
      // デバッグ用
      console.log('query: ', print(newNode))
      console.log('ast: ', newNode)
      onChangeNode(newNode)
      return;
    },
    debug() {
      console.log(root);
    },
    getNode() {
      return root;
    }
  };
  return <NodeContext.Provider value={api} children={children}></NodeContext.Provider>;
}

Editor.tsx

ここがだいぶカオスになっています。
まずは、event handler から定義します。
これは input event を検知して、AST に直接変更を加えて更新しています。
_node は全体の AST、node は今回は引数のみの変更を許可してるので ArgmentsNode になっています。

const onChangeArgments = ({
  _node,
  node,
  currentTarget, 
}: {
  _node: DocumentNode;
  node: ArgumentNode;
  currentTarget: HTMLInputElement
}) => {
  _node.definitions.map(v => {
    if (v.kind === 'OperationDefinition') {
      v.selectionSet.selections.map(vv => {
        // @ts-ignore
        vv.arguments.map(vvv => {
          // このネストの仕方はヤバい気がする
          if (vvv.name.value === node.name.value) {
            vvv.value.value = currentTarget.value;
          };
        });
      });
    }
  });
}

AST を実際にレンダリングするために、ASTRender というコンポーネントを定義しています。これは AST を引数に渡してあげると、node.kind が Document などのオブジェクトとして持っているときはその中身をもう一度 ASTRender コンポーネントに渡すことで、再帰的に木構造を端から網羅するようになっています。
今回の変更は、ArgmentsNode のみ許可しています。そのため、それ以外の場所は再帰で呼んでレンダリングするだけ、ArgmentsNode の部分は form を挿入して複雑になっています。
GraphQL(に限った話ではないですが)の AST は node に対して識別子として kind という key がついています。そこを見てどこの node なのかを判断しています。
例えば、木の root である Document を判定する際には以下のようになります。definitions はリストになっていて、この中に詳細な定義とそれぞれの木構造が広がっています。definitions の中の1つ1つの木に対して、再帰的に ASTRender を呼んで、徐々に展開していきます。

  if (node.kind === 'Document') {
    return (
      <>
        {
          node.definitions.map((n, i) => (
            <ASTRender node={n} key={i} /> 
          ))
        }
      </>
    );
  };

他の部分の AST の展開とレンダリングについては省略しますが、今回エディタとして定義したのは node.kind が Argment の部分です。
ここでは、ArgmentsNode を受け取りレンダリングしますが、現状、StringValue と IntValue しかレンダリングすることができません。他の BooleanValue などなどのプロパティについては追々やっていきます。
中では、先ほど定義した onChangeArgments を呼び出して event を受け取り、更新をしています。また、context.tsx で定義した updateNode を使用して全体の AST を更新しています。

  if (node.kind === 'Argument') {
    if (node.value.kind === 'StringValue') {
      const handleChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
        const _node = context.getNode() as DocumentNode;
        onChangeArgments({ _node, node, currentTarget });
        context.updateNode(node, _node);
      };

      return (
        <>
          <ASTRender node={node.name} /> { ':' } <input type="text" value={node.value.value} onInput={handleChange}></input> { ', ' } <br />
        </>
      );
    }
    
    if (node.value.kind === 'IntValue') {
      const handleChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
        const _node = context.getNode() as DocumentNode;
        onChangeArgments({ _node, node, currentTarget });
        context.updateNode(node, _node);
      };

      return (
        <>
          <ASTRender node={node.name} /> { ':' } <input type="number" value={node.value.value} onInput={handleChange}></input> { ', ' } <br />
        </>
      );
    }
  };

今は StringValue は <input type="text" /> をレンダリングするようにしていますが、ここは正直微妙で、select box や radio button、textarea なども StringValue で管理したいという問題があるので、この先どうしようか迷っています。
form から query を作ること自体は容易ですが、AST から form を作り、さらに query まで展開するのは正直ちょっと厳しい気がしています。ここらへんについてアドバイスなどあったら欲しいです。

Preview.tsx

ここはただ受け取った query を表示するだけなのでシンプルになっています。

import { ASTNode } from "graphql";

export const Preview = (
  { 
    query, 
    ast 
  }: { 
    query: string, 
    ast: ASTNode, 
}) => {
  return (
    <div style={
      {
        width: '100%', 
        textAlign: 'left', 
        paddingLeft: '50px'
      }
    }>
      <pre style={{ background: '#FFFFF0' }}>
        <code>
            {query}
        </code>
      </pre>
    </div>
  );
}

はまりどころ

ハマったというか、まだ解決してませんが、AST のプロパティが StringValue だったときにどうレンダリングするかです。form の形式的に StringValue で対応したいけど、StringValue からは複数を生成できない、何かフラグを持たせる必要があるけど正規のやり方ではないという問題があります。あまりいい方法は思い付いていませんが、そこはもう無理やり独自実装にしてしまうのがありなのではないかなと思っています。わかりませんが。

また、これは凡ミスですが、Preact では compat を使わないと onChange が使えないということも少しハマっていました。ドキュメントは読みましょう。

まとめ

まだ試作段階のプロトコーディングですが、意外と形になるものができててよかったなと思います。
ただ、AST の更新方法や Context の管理、レンダリングのサイクル、event の管理などなど課題が残りますが、これからブラッシュアップしていきたいなと思います。