既存のdbからgraphqlのschemaを生成しようとしてみる
はじめに
既存のDBのURLを渡すと、何か良い感じにgraphqlのベースのapiを良い感じに提供してくれるようにする何かを作ろうとしはじめた。 graphqlはschemaを取るのだけれど、こちらのschemaはgraphベースなのでちょっと困る。 サーバー側の実装をするためにはforeignkeyやrelationの情報を知りたいのでいきなりgraphql用のschemaを生成してはだめ。
そんなわけでschemaを作る手前段階の中間的なファイルを生成する。
その後、作った中間表現からgraphql用のschemaを生成してみる。
もくろみ
sqlalchemyのautomapの機能を使うとそれなりに手軽にできるのような気がした。
とりあえず以下の事が全部分かるようなデータを作ると良い。
- 存在するテーブルの情報
- テーブルの持つフィールドの情報
- テーブルの持つ関係(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("hello") </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にこだわらなくても良いのだけれど。