ラベル SQLAlchemy の投稿を表示しています。 すべての投稿を表示
ラベル SQLAlchemy の投稿を表示しています。 すべての投稿を表示

2013年8月20日火曜日

SQLAlchemy を使ってメタプログラミングを語りき

概要
 前回に引き続き SQLAlchemy を使って python 上で SQL のテーブルを司るクラスを動的に生成することで、簡単に実装する方法を提案する

対象者
  危険なコードを含むので python 初心者、初級者は対象外。中級もしくは、これが商品や公開コードには用いるべきでないとわかるレベル。


背景
 pythonのORMを色々試してみた をはじめとして、 SQLAlchemyを使って動的に SQLを読み込む方法は書かれているが、予めテーブルの定義などが必要となっており正直めんどくさい。動的に定義自体も行う仕組みを作ることで、コード量を減らしたい



準備
  1. 環境は windows7, pythonは2.7.3
  2. python に sqlalchemy(0.8.2)を pip か easy_install なりを使って入れておく
  3. 前回のSQL ファイル("test.sql")をsqliteに書いたDB alembic_test.db をローカルに作る

 test.sql
DROP TABLE IF EXISTS REFERENCE;
CREATE TABLE PACAKGE(
    _ID INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT,
    NAME TEXT NOT NULL UNIQUE,
    OWNER TEXT
)


 実装

説明するより物を見たほうが早い

 test.py 
# -*- coding: utf-8 -*-
from sqlalchemy.engine import create_engine
from sqlalchemy import MetaData, Table
from sqlalchemy.orm import mapper, sessionmaker

# point 1
from sqlalchemy.interfaces import PoolListener
class SetTextFactory(PoolListener):
    def connect(self, dbapi_con, con_record):
        dbapi_con.text_factory = str
engine = create_engine('sqlite:///alembic_test.db', listeners=[SetTextFactory()])
metadata = MetaData(bind=engine)
metadata.reflect()
session = sessionmaker(bind=engine)()

# point 2
classref = {"__init__": lambda x, **y : (lambda x: None) \
    ( [ x.__dict__.setdefault(y1,y2) for y1, y2 in y.items() if y1 not in x.__dict__ ] ) }

# point 3
for t in metadata.sorted_tables:
    globals()[t.name]= type(str(t.name), (object,), classref)
    try:
        mapper(globals()[t.name], Table(t.name, metadata, autoload=True))
    except:
        pass

# point 4
pacakge=PACAKGE(NAME="candy", OWNER="Tom")
session.add(pacakge)
pacakge=PACAKGE(NAME="チョコレート", OWNER="田中")
session.add(pacakge)
pacakge=PACAKGE(NAME="cake", OWNER="Sully")
session.add(pacakge)
session.commit()

package= session.query(PACAKGE).order_by(PACAKGE._ID.desc()).first()
print package.NAME, package.OWNER

session.close()
point 1
半分以上がおまじないに近い。
SetTextFactory で
dbapi_con.text_factory = str
とやっているのは、こうしないと データベース が日本語を受け付けないため。
あとは、DBファイルを開いてアクセス用のコネクションを張っているだけ。

point 2
 python に慣れていない人にはつらいものが多いと思われる。
classref  は辞書型のオブジェクトだが、point 3 で使用する関数 __init__() を定義している。 __init__() 内部は lamda * 2 とリストの内包表記 (list comprehension)を組み合わせている。

もともとは、以下の様な

 def __init__(x, **y):
    for y1, y2 in y.items():
        if y1 in x.__dict__:
            x.__dict__[y1]=y2
    return None  

初期化で渡された辞書情報 y をオブジェクト x のフィールドに登録する仕組み。
これを一行に書いているだけなのだ。

 forブロックを一つにまとめると
 def __init__(x, **y):
    [ x.__dict__.setdefault(y1,y2) for y1, y2 in y.items() if y1 not in x.__dict__ ]
    return None  
とリストの内包表記を使うこととなる。 リストの内包表記は "=" が使えないため seddefault() を使うところが工夫といえば工夫。更に lamda を一回はさむと

 def __init__(x, **y):
    (lambda x: None)([ x.__dict__.setdefault(y1,y2) for y1, y2 in y.items() if y1 not in x.__dict__ ])

となる。要は、Noneを返す関数を動的に作成している。そして引数にリスト内包表現を使う。通常、関数を呼ぶときは引数の式を評価してから動作することを利用している。最適化されたら無視されて動作しなくなるのだろうかと不安になるのは正しい反応。このように無理やり式を評価させつつ None を返す方法としては、以下の方法もあるが見栄えが悪い

None if map(lambda (y1, y2) :  x.__dict__.setdefault(y1, y2) \
    if y1 not in x.__dict__ else None, y.items()) else None


pont 3
 ここが今回のポイント sql のテーブル一覧を for で回した後、取得したテーブル情報 t をもとに、動的に生成したクラスを、動的に生成したグローバル定義に登録している。何を言っているか非常にわかりづらいが、 t.name が "PACAKGE" だった場合、

globals()[t.name]

とすると、PACKAGEはプログラム中どこでも使えるようになるため、その後はいきなり文中で PACKAGE と書いても未定義エラーにならない。じゃ、その PACKAGE には何が入るかというと、動的に生成されたクラスとなる。

type(str(t.name), (object,), classref)

は、 t.name のクラスを、 object のサブクラスとして定義して point 2で設定した初期化関数を追加して作成している。こうすることで、

PACKAGE( bar=1, foo=2 )

とすると、PACKAGEクラスのオブジェクトを生成できるようになる。
このままでは何もできないので、

mapper(globals()[t.name], Table(t.name, metadata, autoload=True))

とすることで、sql の実際のテーブルと関連付けを行っている


pont 4
ここまでが下準備で、あとは使ってみるだけである。テーブル名でクラスは定義されているため、test.sq lを見ながらコーディングをする。、 PACAKGEというクラスでデータを生成しては挿入した後に、検索をして、最後に挿入した一つを表示するようにしている。


まとめ
  このように、python は使えば使うほど手を抜く事ができる。本旨は point 3 だが、 point 2 でも一般的な python を使う上でのテクニックも紹介した。あえて触れていないが exception で何もしていないため、いろいろ危険である。分からない場合は使わないことをお勧めする。実際の私の環境では課題が起きにくいようにしているが、更に複雑で説明のしようがない。説明優先で簡単に書いているのでかんべんしてもらいたい

2013年8月17日土曜日

SQL の migration を SQLAlchemy で語りき

概要
 SQLAlchemy の migration 用ツール alembic でDBの初期設定を SQL script のデータを流し込み効率化する(sqliteでのみ検証)


対象者
 SQLAlchemyを軽度に使っている初級以上の python ユーザー、もしくは Rails 等の軽量フレームワークを使っている人


背景
 今年の GW に Ruby on Rails (以下 Rails) を触ってみて、いかんせん小骨に刺さっていたことがある。なぜ彼らは”SQL構文を覚える必要がない”ことを強調しているのだろうか。
 サーバーサイドの開発はかなり効率化されている。MVC の分離に始まり、ルーティング、テンプレートやテストツール、そしてデータベースの構造を Migrate してバージョン管理しやすいようにしている。だが、その実現には SQL 構文ではなく、専用の構文を覚えることを強制している。別に強制は珍しい話ではない。多くの軽量フレームワークで行われていることだ。Rails の設定には yaml を使うことや、python の flask ではテンプレートに jinja2 を使うことなどである。そもそも、業界だって sass や node.js など覚えるべきことはどんどん増えている。ソフトウェア業界にいる以上、このような現象は当然であり、諦めるしかないことと皆覚悟している。このような状況で SQL だけを覚える必要がないというのはあまりに滑稽で、言い逃れに聞こえかねない。だとすると、本音は違うところにあると考えるのが自然だろう。実際は、SQL 構文を使わせないようしたいのだ。

 使わせない理由は、SQL の文法は差分管理に向かないことだろう。中身のデータは永続化しているが、構造は開発中は日々変更されるし、運用中の変更もありえる。SQL構文にはその差分管理を記載する方法はない。ALTER構文があるがバージョンと紐付かないのである。つまり、継続的インテグレーションには不向きだということだ。だからといって、SQL 構文を全く使わせないのはおかしい。例えば、DB の設計では ER 図を用いて行うことが多い。僕の場合は Eclipse の ERMaster plug-in を用いて行うが出力は SQLのスクリプトで出すことが多い。差分管理には向かないにせよ、最初の立ち上げには SQLのスクリプトファイル でDDLを作り、それを流しこむことで初期テーブルを作ったほうが立ち上げ効率は良いだろう

イメージ的には
rails generate scaffold friend name:string address:string

rails generate scaffolds file=friend.sql

とできればどれだけ楽だろうか。
これができないから SQLのスクリプトを各フレームワーク用のスクリプトに手作業翻訳とはありえない話だ。と、いうことで Rails でやり方を延々と探していたが、ruby はまだまだ弱いので見つけられなかった。その中で使い慣れた python を用いた方法が見つかった。


準備
  1. 環境は windows7 。pythonは2.7.3
  2. python に sqlalchemy(0.8.2), alembic(0.6.0), sqlahelper(不明) を pip か easy_install なりを使って入れておく。
  3. > mkdir alembic_test # ワーキングディレクトリ
  4. > cd alembic_test
  5. > alembic init alembic # alembic はプロジェクト名なので適当でよい
  6. フォルダ直下に DDLのsqlスクリプト "test.sql" を置く



test.sql
DROP TABLE IF EXISTS REFERENCE;
CREATE TABLE PACAKGE(
    _ID INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT,
    NAME TEXT NOT NULL UNIQUE,
    OWNER TEXT
)
実装

alembic.ini を開き読み込み用の SQL スクリプト名を書いておく
+ base.script = test.sql
alembic/env.py
を以下のように変更する

# from myapp import mymodel
# target_metadata = mymodel.Base.metadata

- target_metadata = None
+ import os
+ # point 1
+ sqlscript=config.get_main_option("base.script")
+ if len(context.script.get_heads()) is 0 and os.path.isfile(sqlscript):
+     # point 2
+     with open(sqlscript) as f:
+         from sqlalchemy.engine import create_engine
+         from sqlalchemy import MetaData
+         engine = create_engine(config.get_main_option("sqlalchemy.url"))
+         connection = engine.connect()
+         for script in f.read().split(";"):
+             connection.execute(script)
+         connection.close()
+
+     # point 3
+     import sqlahelper
+     target_metadata=sqlahelper.get_base().metadata
+
+     # point 4
+     old_fn = context._proxy.context_opts['fn']
+     def migration_aspect(rev, _context):
+         old_fn(rev, _context)
+         template_args = context._proxy.context_opts['template_args']
+         opts = _context.opts
+         tmp = template_args[opts['upgrade_token']]
+         template_args[opts['upgrade_token']] = \
+             template_args[opts['downgrade_token']]
+         template_args[opts['downgrade_token']] = tmp
+         return []
+     context._proxy.context_opts['fn'] = migration_aspect
+ else:
+     target_metadata=None
+
+ #target_metadata = None
順に説明しよう

point 1
config.get_main_option()でalembic.iniに設定した読み込み用の sqlスクリプト"test.sql"を取得し、
ファイルの存在確認と、このスクリプトが一度目しか通らないように head という履歴情報にデータがないことを確認する

point 2
"test.sql"を読み込んで、とにかくdbファイルを作る
この方法は SQLAlchemy tutorial — MapFis を参考にした

pont 3
alembic には autogenerate という現在のDBの状況をスクリプト上の設定DBに変換するための自動生成機能がある。その設定には、target_metadata に特定のオブジェクトをはめる必要がある。今回は alembic使ってみた - podhmoの日記 を参考に point 2 で作ったdbを読み込むこととする

point 4
先ほどまでのものだけだと、バージョンアップすると、空のDBが作られ、バージョンダウンするとpoint 2のテーブルが作られるスクリプトが生成される。望ましいのと逆なのだ。手作業で入れ替えるのも面倒なのでむりやり、autogenerate が動作した時の変換関数をラッピングして、処理後にバージョンアップコードとバージョンダウンコードを入れ替えてしまう。かなり強引だが、特定のファイルのみの変更で済ませるにはこれしかなかった


実行と結果
実装が手間だった分、ここから先は alembic の仕組みに従うので簡単
> alembic revision --autogenerate -m "generate"
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate] Detected removed table u'PACAKGE'
INFO  [alembic.autogenerate] Detected removed table u'sqlite_sequence'
  Generating ***\alembic\versions\21b9c4f42450
_g
  enerate.py...done
 自動生成された ****\alembic\versions\21b9c4f42450_generate.py を開くと、
"""generate

Revision ID: 21b9c4f42450
Revises: None
Create Date: 2013-08-17 00:00:58.812000

"""

# revision identifiers, used by Alembic.
revision = '21b9c4f42450'
down_revision = None

from alembic import op
import sqlalchemy as sa


def upgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.create_table(u'sqlite_sequence',
    sa.Column(u'name', sa.VARCHAR(), nullable=True),
    sa.Column(u'seq', sa.VARCHAR(), nullable=True),
    sa.PrimaryKeyConstraint()
    )
    op.create_table(u'PACAKGE',
    sa.Column(u'_ID', sa.INTEGER(), nullable=False),
    sa.Column(u'NAME', sa.TEXT(), nullable=False),
    sa.Column(u'OWNER', sa.TEXT(), nullable=True),
    sa.PrimaryKeyConstraint(u'_ID')
    )
    ### end Alembic commands ###


def downgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.drop_table(u'PACAKGE')
    op.drop_table(u'sqlite_sequence')
    ### end Alembic commands ###

と test.sql に対応したものが出来上がる。


まとめ
 実際には、8個程度のテーブルを使っているが難なく作ることができた。60行程度書くのが嫌なために、かなり時間をかけたものである。また、フレームワーク標準搭載のリレーションテーブルが作られてしまうので、Python の O/Rマッパー SQLAlchemy を使ったリレーショナルマッピング基本 4 パターン | CUBE SUGAR STORAGE でも見ながら手直しが必要だろう。
 最近は継続的な開発ばかり目がいっているが、立ち上げの効率化ほど重要な事はない。
このあたりのツールがもっと揃うことを切に願うばかりだ