既存のdbからgraphqlのschemaを生成しようとしてみる

はじめに

既存のDBのURLを渡すと、何か良い感じにgraphqlのベースのapiを良い感じに提供してくれるようにする何かを作ろうとしはじめた。 graphqlはschemaを取るのだけれど、こちらのschemaはgraphベースなのでちょっと困る。 サーバー側の実装をするためにはforeignkeyやrelationの情報を知りたいのでいきなりgraphql用のschemaを生成してはだめ。

そんなわけでschemaを作る手前段階の中間的なファイルを生成する。

その後、作った中間表現からgraphql用のschemaを生成してみる。

もくろみ

sqlalchemyautomapの機能を使うとそれなりに手軽にできるのような気がした。

とりあえず以下の事が全部分かるようなデータを作ると良い。

  • 存在するテーブルの情報
  • テーブルの持つフィールドの情報
  • テーブルの持つ関係(relation)の情報

やってみる

やってみた。あとでまじめに綺麗にするけれど。とりあえずのプロトタイプとしては良い感じ。

以下のようなtableを用意した。

CREATE TABLE childs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT
);

CREATE TABLE kinds (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL
);


CREATE TABLE dogs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    kind_id INTEGER,
    dog TEXT,
    FOREIGN KEY(kind_id) REFERENCES kinds(id)
);

CREATE TABLE child_dogs (
    child_id INTEGER,
    dog_id INTEGER,
    FOREIGN KEY(child_id) REFERENCES childs(id),
    FOREIGN KEY(dog_id) REFERENCES dogs(id)
);

dogsとchildsがchild_dogs経由でmany to many。dogとkindにfkが貼られている。 (database上ではone to oneとone to manyのどちらであるかは決められないので注意)

sqlite上でtableを生成して、生成したdbの情報をgen.pyというファイルに渡す(後述)。

$ cat create.sql | sqlite3 dog.db
$ python gen.py 'sqlite:///./dog.db'

以下のような中間表現を得られる。粗削りではあるけれど。

{
  "dogs": {
    "kinds": {
      "uselist": false,
      "direction": "MANYTOONE",
      "type": "kinds",
      "relation": {
        "to": "dogs.kind_id",
        "from": "kinds.id"
      }
    },
    "id": {
      "nullable": false,
      "type": "ID"
    },
    "kind_id": {
      "nullable": true,
      "type": "int"
    },
    "dog": {
      "nullable": true,
      "type": "str"
    },
    "childs_collection": {
      "uselist": true,
      "direction": "MANYTOMANY",
      "type": "childs",
      "relation": {
        "to": "child_dogs.dog_id",
        "from": "dogs.id"
      }
    }
  },
  "kinds": {
    "id": {
      "nullable": false,
      "type": "ID"
    },
    "name": {
      "nullable": false,
      "type": "str"
    },
    "dogs_collection": {
      "uselist": true,
      "direction": "ONETOMANY",
      "type": "dogs",
      "relation": {
        "to": "dogs.kind_id",
        "from": "kinds.id"
      }
    }
  },
  "childs": {
    "dogs_collection": {
      "uselist": true,
      "direction": "MANYTOMANY",
      "type": "dogs",
      "relation": {
        "to": "child_dogs.child_id",
        "from": "childs.id"
      }
    },
    "id": {
      "nullable": false,
      "type": "ID"
    },
    "name": {
      "nullable": true,
      "type": "str"
    }
  }
}

存在するテーブルの情報

dogs, childs, kindsのテーブルがあることが分かる

テーブルの持つフィールドの情報

typeがあるものがフィールド。foreign keyとして扱われるものはIDになっている。

テーブルの持つ関係(relation)の情報

relationのfromとtoがわかり、directionも分かるので良さそう。

gen.py

作ったスクリプトは以下の様な感じ。

from collections import OrderedDict
from sqlalchemy.ext.automap import automap_base
from sqlalchemy import create_engine
from sqlalchemy.inspection import inspect


def collect(classes, getname=str):
    d = OrderedDict()
    for c in classes:
        mapper = inspect(c)
        d[mapper.local_table.fullname] = _collect_from_mapper(mapper)
    return d


def _collect_from_mapper(m):
    d = OrderedDict()
    for prop in m.iterate_properties:
        if hasattr(prop, "direction"):
            pairs = prop.synchronize_pairs
            assert len(pairs) == 1, "multi keys are not supported"
            d[prop.key] = {
                "table": prop.target.fullname,
                "direction": prop.direction.name,
                "uselist": prop.uselist,
                "relation": {
                    "to": "{}.{}".format(pairs[0][0].table.fullname, pairs[0][0].name),
                    "from": "{}.{}".format(pairs[0][1].table.fullname, pairs[0][1].name),
                }
            }
        else:
            assert len(prop.columns) == 1, "multi keys are not supported"
            c = prop.columns[0]
            d[prop.key] = {
                "type": "ID" if c.primary_key else c.type.python_type.__name__,
                "nullable": c.nullable,
            }
    return d


def main(src):
    Base = automap_base()
    engine = create_engine(src)
    Base.prepare(engine, reflect=True)

    from dictknife import loading
    d = collect(Base.classes)
    loading.dumpfile(d, format="json")


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--src", default="sqlite:///./dog.db")
    args = parser.parse_args()
    main(args.src)

中間表現からgraphqlのschemaを生成しようとしてみる

先程作った中間表現JSONファイルを利用してgraphqlのschemaを作ってみる。

やってみた結果

名前があんまりよろしくないけれど。以下の様な感じになる。

# gen.json は先程生成した中間表現のJSON
$ python convert.py gen.json

このような結果が得られる

type Child {
    dogs_collection: [Dog]
    id: ID!
    name: String
}
type Dog {
    kinds: Kind
    id: ID!
    kind_id: Integer
    dog: String
    childs_collection: [Child]
}
type Kind {
    id: ID!
    name: String!
    dogs_collection: [Dog]
}

テーブル名から型名を作っているので少し違和感のある名前かも知れない(手抜きをしたかったのでchildrenではなくchildsという名前だった)。 まじめに調べていないので型の書き方が間違っているかもしれないけれど。とりあえずプロトタイプなので。

もう少し後でドキュメントなどを見直す必要がある。このあたり

そう言えば、unionとかenumには対応してない。

コード

コードはこんな感じ。 prestringとdictknifeが必要。

# -*- coding:utf-8 -*-
from dictknife import loading
from prestring import Module
import contextlib
import logging
logger = logging.getLogger(__name__)


def titleize(name):
    if not name:
        return name
    return name[0].upper() + name[1:]


def singular(name):
    if name.endswith("s"):
        return titleize(name[:-1])
    return titleize(name)


class Array:
    def __init__(self, t):
        self.t = t

    def __str__(self):
        return "[{}]".format(self.t)


class GraphQLModule(Module):
    @contextlib.contextmanager
    def type_(self, name):
        self.stmt("type {} {{", name)
        with self.scope():
            yield
        self.stmt("}")

    def field(self, name, typ, nullable=True):
        if nullable:
            self.stmt("{}: {}", name, typ)
        else:
            self.stmt("{}: {}!", name, typ)


def emit(m, d):
    for name, fields in d.items():
        with m.type_(singular(name)):
            for k, v in fields.items():
                if "type" in v:
                    m.field(k, v["type"], nullable=v.get("nullable", True))
                else:
                    if v["uselist"]:
                        m.field(k, Array(singular(v["table"])), nullable=v.get("nullable", True))
                    else:
                        m.field(k, singular(v["table"]), nullable=v.get("nullable", True))


def main(src):
    d = loading.loadfile(src)
    m = GraphQLModule()
    emit(m, d)
    print(m)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("src")
    args = parser.parse_args()
    main(args.src)

docutils用にsphinxのliteralincludeのsubsetを作って使ってみる。

literalinclude?

sphinxで利用可能なdirectiveの1つ。 以下の様な形で別のファイルに定義した記述をあたかも自身のcode-blockとして記述したかのように使えるもの。

ここはReSTの文章。

.. literalinclude:: <filename.<ext>>


ここもReSTの文章。

似たようなものとしてincludeが存在する。

  • include 外部ファイルをReSTとして取り込む
  • literalinclude 外部ファイルを(特定の言語の)code-blockとして取り込む

直接流用出来ないか試す

直接sphinxのdirectiveを流用出来ないか試してみる。元にするのはrst2html5

my_rst2html5.py

from docutils.core import publish_cmdline, default_description
from docutils.parsers.rst import directives
from sphinx.directives.code import LiteralInclude

directives.register_directive("literalinclude", LiteralInclude)
description = (
    u'Generates HTML 5 documents from standalone '
    u'reStructuredText sources ' + default_description
)

publish_cmdline(writer_name='html5', description=description)

使ってみる。動かない。

$ cat <<-EOS > 00hello.rst
hello
========================================

.. literalinclude:: 00hello.py

hai
EOS
$ cat <<-EOS > 00hello.py
print("hello")
EOS
$ python my_rst2html.py --traceback 00hello.rst
...
    result = directive_instance.run()
  File "/home/podhmo/venvs/my3/lib/python3.5/site-packages/sphinx/directives/code.py", line 414, in run
    env = document.settings.env
AttributeError: 'Values' object has no attribute 'env'

普通に怒られる。document.settings.envというのはsphinxに含まれているEnvironmentオブジェクトのことなので普通には動かない。

諦めてsubsetを作る

以下の様な感じにするとsubsetを作れなくはない。

import os.path
from docutils.core import publish_cmdline, default_description
from docutils.parsers import rst
from docutils import nodes
from docutils.parsers.rst import directives


class LiteralInclude(rst.Directive):
    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True
    option_spec = {'dedent': int, }

    def run(self):
        document = self.state.document
        dedent = self.options.get("dedent", 0)

        try:
            filename = self.arguments[0]
            filepath = os.path.join(os.path.dirname(document.settings._source), filename)
            with open(filepath) as rf:
                text = rf.read()

            if dedent > 0:
                text = "".join([line[dedent:] for line in text.splitlines(True)])
            retnode = nodes.literal_block(text, text, source=filename)
            self.add_name(retnode)
            return [retnode]
        except Exception as exc:
            return [document.reporter.warning(str(exc), line=self.lineno)]


directives.register_directive("literalinclude", LiteralInclude)
description = (
    u'Generates HTML 5 documents from standalone '
    u'reStructuredText sources ' + default_description
)

publish_cmdline(writer_name='html5', description=description)

今度は動く。subsetなので完全な互換性はない。

$ python my_rst2html5.py --traceback hello.rst
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">

...snip

<body>
<div class="document" id="hello">
<h1 class="title">hello</h1>

<pre class="literal-block">print(&quot;hello&quot;)
</pre>
<p>hai</p>
</div>
</body>
</html>

ところで

gitstはこちら。 ところで、rawgitというサービスを使うとgist上にuploadしたhtmlのレンダリング結果を見ることができるらしい。

こういう感じに(上の文章中のものとはちょっと違うコードではあるけれど)。

ただ、rst2html5で生成されるhtmlはやっぱりスマートフォンではあんまり良い感じの見た目にならない感じっぽい(少なくともviewportの指定などが不足している)。かなしい。

そもそもしたかったことは

そもそもなんでこんなことをしたかったかというと、sphinxのように複数のページから成るドキュメントを作りたいのではなく。単なる1枚のページをそれなりに綺麗な見た目で作りたいのだけれど。その1枚のページを作るための入力のファイル自体は分けたいと思う事が多かったので。sphinxだとちょっとover-killっぽい。

markdownでReSTのdirectiveに似たようなものを作る方法が確立して一般化されているなら(ユーザーが新たな文法を増やさずとも好き勝手に機能を追加できる記法が存在する)別にdocutilsというかReSTにこだわらなくても良いのだけれど。