TL;DR
最終的に長くなってしまったので。
- (Flask-)SQLAlchemyを使うと、Python上のモデル操作で、実際のデータベース構造を変更(参照)できる。
- Flask-Migrateは通常イメージするデータベース間の「移行」ではなく、Python上のモデルから、実際のデータベースへの「変換」を行うモジュール。
- Flask-Migrate(Alembic)を使うと、Python上のモデルと実際のデータベース構造の同期がとりやすい。
Flask-Migrateとは
Flask-Migrate is an extension that handles SQLAlchemy database migrations for Flask applications using Alembic.
Alembicを使って、SQL Alchemyによるデータベースマイグレーションを扱うためのExtension。これだけ読んでも意味がわかりませんでしたが、使ってみると用途がわかりました。
ここでいうマイグレーションとは、通常イメージするデータベース間の「移行」ではなく、Python上のモデルから、実際のデータベースへの「変換」を表しているという理解です。
Python上のデータベースモデル(Flask-SQLAlchemyについて)
pythonでデータベースを扱う方法はいくつかありますが、その1つはSQLAlchemyです。これはORM(Object-Relational Mapping)の1つで、オブジェクト志向言語のオブジェクトを、そうでないもの(ここではRelational Database)に変換するためのモジュールになります。
ここではFlask内で扱うことを前提に、flaskの拡張であるflask-sqlalchemyを使用します。
In[2]: from flask import Flask
In[3]: from flask_sqlalchemy import SQLAlchemy
# 最初にFlask自体をappという名前でインスタンス化します。
In[4]: app = Flask(__name__)
# 次にいくつかパラメータを設定します。
# sqliteのデータベース(ファイル)をどこに配置するか指定します。
In[5]: app.config['SQLALCHEMY_DATABASE_URI'] ='sqlite:///C:\\temp\\flask\\app.db'
# TRACK_MODIFICATIONという機能を無効化します。(後述)
In[7]: app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# データベースをインスタンス化します。flaskの拡張であるflask-sqlalchemyを使用しているため、Flaskインスタンスを渡してデータベースを作っている点に注意です。
In[8]: db = SQLAlchemy(app)
# テーブルは、dbがもつModelというクラスを継承させた、Pythonのクラスとして作成します。
In[9]: class User(db.Model):
...: id = db.Column(db.Integer, primary_key=True)
...: username = db.Column(db.String(64), index=True, unique=True)
...: email = db.Column(db.String(120), index=True, unique=True)
...:
# 作成したクラス(テーブル)を実際にSQLite上に作成するのはcreate_allというメソッドです。
# この時点で、指定したディレクトリにapp.dbというファイルが作成されます。
In[13]: db.create_all()
# テーブルの確認
# ※for_bindじゃないものがあってもよさそうですが、見た感じこれしかありません。
In[14]: db.get_tables_for_bind()
Out[14]: [Table('user', MetaData(bind=None), Column('id', Integer(), table=<user>, primary_key=True, nullable=False), Column('username', String(length=64), table=<user>), Column('email', String(length=120), table=<user>), schema=None)]
# テーブルの中身をクエリしてみます。(この時点では何もなし)
In[15]: User.query.all()
Out[15]: []
# エントリを作成します。エントリは対応するクラスのインスタンスとして表現されます。
In[16]: u = User(username='Alice', email='alice@example.jp')
# DBへの追加の際はセッションを意識する必要があります。
# セッションを通して追加し(この追加は複数可能)、最後にcommitすることでデータベースに反映されます。
In[17]: db.session.add(u)
In[18]: db.session.commit()
# 内容の確認
In[19]: User.query.all()
Out[19]: [<User 1>]
In[21]: User.query.get(1).email
Out[21]: 'alice@example.jp'
ちなみに、app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
を指定しないと、以下のような警告が表示されます。
In[6]: db = SQLAlchemy(app)
C:\Users\ikedak2\PycharmProjects\TestSandbox\venv\lib\site-packages\flask_sqlalchemy\__init__.py:794: FSADeprecationWarning: SQLALCHEMY_TRACK_MODIFICATIONS adds significant overhead and will be disabled by default in the future. Set it to True or False to suppress this warning.
'SQLALCHEMY_TRACK_MODIFICATIONS adds significant overhead and '
もう1つ補足として、クラスはUserなのに、実際にできるテーブルはuserです。これはSQLAlchemyが対応するクラスの名前に基づいて、自動的にテーブル名を付与するためです。規則はsnake caseで、全て小文字、かつ区切りはアンダースコアになります。これを指定したければ、モデルクラスの中で、__tablename__を指定すればよいらしい。
SQliteでのセッションエラーについて(2019/06/26追記)
commitした直後にqueryすると、以下のようなエラーで失敗する事象にあいました。原因は、PyCharmでほかのプロジェクトを開いていて、そこでも同じようにコンソールで、同じ名前(db)でfrom app import db
していたためでした。
>>> db.session.commit()
>>> User.query.all()
sqlalchemy.exc.ProgrammingError: (sqlite3.ProgrammingError) SQLite objects created in a thread can only be used in that same thread.
Flask-Migrateによるデータベース操作、履歴管理
先の項目では、sqlalchemy(Flask-SQLAlchemy)によってデータベース、テーブルの作成を行いました。簡単な構成ではこれでも事足りますが、構成が複雑だったり、同じ構成を他で複製したい場合などは、毎回手動で作成するのは大変です。Flask-Migrateを使うと、モデル設計→実際のデータベース構成の変換が自動で実行され、さらに履歴も管理することができます。
先の項目ではPythonのコンソールから操作しましたが、ここからは上記のような使用例を考えて、アプリケーションをファイル化します。(githubとかで配布できる形を想定)
下準備
以下のようなフォルダ構成をとります。
TestSandBox
├venv/
├.flaskenv
├test_flask-migrate.py
Flask-Migrateはflask db xxというflaskのサブコマンドで操作します。flaskコマンドを使用するにあたり、大元になるFlaskインスタンスがどこで定義されているか(app=Flask()がどこで実行されているか)を指定する必要があります。デフォルトはapp.pyまたはwsgi.pyのようです。
参考
この指定はFLASK_APPという環境変数で行います。コンソールで実行する場合は、通常通りset FLASK_APP='test.py'
のように指定しますが、これをファイル化するにはpython-dotenvというモジュールを使用します。
まず実行している仮想環境などで、pip install python-dotenv
を実行します。
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>pip install python-dotenv
そのうえで、ルートディレクトリに.flaskenvというファイルを作成し、以下のように記載します。
FLASK_APP=test_flask-migrate.py
これでflaskコマンドが実行されると、test_flask-migrate.pyが読み込まれます。
次に、前の項目でコンソールから実施した内容と同じようなことをファイルにします。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask
import os
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
# 実行されるファイル(test_flask-migrate.py)の置き場所をbasedirに保存
basedir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__)
# データベースファイルは実行ファイルと同じ場所にapp.dbという名前で作成
app.config['SQLALCHEMY_DATABASE_URI'] ='sqlite:///' + os.path.join(basedir, 'app.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# migrateインスタンスを定義
migrate = Migrate(app, db)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
password_hash = db.Column(db.String(128))
# この関数は、インタープリタ(コンソール)からこのクラス(からできたインスタンス)を読んだ際に、どのように表示されるかを定義している。ここではusernameを表示させている。
def __repr__(self):
return '<User {}>'.format(self.username)
2019/06/24追記
ちなみに、ここで作成するpyの名前を、なんとなくflask_migrate.pyとすると、以下のように次のdb initで失敗します。
(venv) C:\Users\ikedak2\PycharmProjects\TimeRecorder2019>flask db init
Usage: flask db init [OPTIONS]
Error: Failed to find Flask application or factory in module "flask_migrate". Use "FLASK_APP=flask_migrate:name to specify one.
これは、作成した「flask_migrate.py」が、ファイル内でimportしているflask_migrateとかぶっているため。flaskアプリケーションを、自分で作成したflask_migrateではなく、外部ライブラリのflask_migrateから読もうとしている。
関数名やモジュール名はほかのものと重複しないように注意する必要があります。
マイグレーションレポジトリの作成
この状態で通常のプロンプトから、flask db init
を実行します。
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>flask db init
Creating directory C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations ... done
Creating directory C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations\versions ... done
Generating C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations\alembic.ini ... done
Generating C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations\env.py ... done
Generating C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations\README ... done
Generating C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations\script.py.mako ... done
Please edit configuration/connection/logging settings in 'C:\\Users\\ikedak2\\PycharmProjects\\TestSandbox\\migrations\\alembic.ini' before proceeding.
このコマンドを実行することで、ルートディレクトリにmigrationというフォルダが作成されます。
マイグレーションの実施
次に、flask db migrate
を実行し、マイグレーション用のスクリプトを生成します。
マイグレーションにはautomaticとmanualの2種類があり、automaticの場合、Python上でモデルに定義された形と、実際のDBのスキーマをAlembicが比較して、必要な差分を埋めるスクリプトを生成します。今回の場合、元のデータベースが存在しないので、データベース自体とUserテーブルを生成するスクリプトが作成されます。
※先ほど作成したデータベースとはディレクトリを使用しているので新規作成です。
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>flask db migrate -m "Create user table"
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'user'
INFO [alembic.autogenerate.compare] Detected added index 'ix_user_email' on '['email']'
INFO [alembic.autogenerate.compare] Detected added index 'ix_user_username' on '['username']'
Generating C:\Users\ikedak2\PycharmProjects\TestSandbox\migrations\versions\182c58313345_create_user_table.py ... done
userというテーブルと、インデックスとなるフィールドがモデルの定義通りに認識されています。
この時点で、migration\versionsフォルダに、182c58313345_create_user_table.pyというファイルが作成されており、これが実際のSQL操作を実行するスクリプトです。ファイル名はバージョンを表しており、すなわちバージョン管理されています。
-mというオプションで、gitのコミットのように、バージョンに対して名前(コメント)を付けることができます。
スクリプトの中には以下のように記載されており、「この変更をどのように実施するか(Upgrade)」と「どのように切り戻すか」(Downgrade)の2つが用意されています。
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=True),
sa.Column('email', sa.String(length=120), nullable=True),
sa.Column('password_hash', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_username'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')
# ### end Alembic commands ###
履歴は以下のように確認できます。
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>flask db history
<base> -> 182c58313345 (head), Create user table
履歴管理されているため、特定のバージョンに戻すことが可能みたいです。(未実施)
マイグレーションの実行
flask db upgrade
を実行して、upgradeの内容(テーブル作成)を実行します。
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 182c58313345, Create user table
実際にできたかどうかは、コンソールから実施した際と同様です。
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>python
Python 3.6.4 (v3.6.4:d48eceb, Dec 19 2017, 06:04:45) [MSC v.1900 32 bit (Intel)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from test_flask_migrate import db
>>> db.get_tables_for_bind()
[Table('user', MetaData(bind=None), Column('id', Integer(), table=<user>, primary_key=True, nullable=False), Column('username', String(length=64), table=<user>), Column('email', String(length=120), table=<user>), Column('
password_hash', String(length=128), table=<user>), schema=None)]
この後は、Python側でモデルに変更を加えたときに、マイグレーションを実行することでデータベースの構成とモデルの同期をとることができます。
感想
今まではORM = PythonからSQLを実行するためのもの、くらいにしか認識していませんでしたが、ようやくORMとか何か、なんとなくわかったように思われます。