Flask-Migrateに関して自分の言葉で

# TL;DR
最終的に長くなってしまったので。

- (Flask-)SQLAlchemyを使うと、Python上のモデル操作で、実際のデータベース構造を変更(参照)できる。
- Flask-Migrateは通常イメージするデータベース間の「移行」ではなく、Python上のモデルから、実際のデータベースへの「変換」を行うモジュール。
- Flask-Migrate(Alembic)を使うと、Python上のモデルと実際のデータベース構造の同期がとりやすい。

# Flask-Migrateとは
[公式](https://flask-migrate.readthedocs.io/en/latest/)
> 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を使用します。

```py3
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`を指定しないと、以下のような警告が表示されます。

```shell-session
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`していたためでした。

```py
>>> 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のようです。
[参考](http://flask.pocoo.org/docs/1.0/cli/)

この指定はFLASK_APPという環境変数で行います。コンソールで実行する場合は、通常通り`set FLASK_APP='test.py'`のように指定しますが、これをファイル化するにはpython-dotenvというモジュールを使用します。
まず実行している仮想環境などで、`pip install python-dotenv`を実行します。

```sh
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>pip install python-dotenv
```

そのうえで、ルートディレクトリに.flaskenvというファイルを作成し、以下のように記載します。

```txt:.flaskenv
FLASK_APP=test_flask-migrate.py
```

これでflaskコマンドが実行されると、test_flask-migrate.pyが読み込まれます。
次に、前の項目でコンソールから実施した内容と同じようなことをファイルにします。

```py3: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で失敗します。

```shell-session
(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`を実行します。

```sh
(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テーブルを生成するスクリプトが作成されます。
※先ほど作成したデータベースとはディレクトリを使用しているので新規作成です。

```shell-session
(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つが用意されています。

```py3
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 ###
```

履歴は以下のように確認できます。

```shell-session
(venv) C:\Users\ikedak2\PycharmProjects\TestSandbox>flask db history
<base> -> 182c58313345 (head), Create user table
```

履歴管理されているため、特定のバージョンに戻すことが可能みたいです。(未実施)

## マイグレーションの実行

`flask db upgrade`を実行して、upgradeの内容(テーブル作成)を実行します。

```shell-session
(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
```

実際にできたかどうかは、コンソールから実施した際と同様です。

```shell-session
(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とか何か、なんとなくわかったように思われます。