Flask-Migrateでハマったけどなんとかなった話

なんとかはなったけど、イマイチ理解はできてない話でもある。

自作の書籍管理WEBアプリに、保管場所を表す BookShelf モデルを追加して、データベース(SQLite3)のアップグレードを実行したところでハマった。

アップグレード前のデータベースには、4つのテーブルがあった。( )内はモデルのクラス名。

  • books (Book)
  • categories (Category)
  • formats (Format)
  • coverarts (CoverArt)

ここに保管場所を記録するための bookshelves テーブルを追加したい。モデルクラスは BookShelf。models.py ファイルにこんなふうに定義した。

class BookShelf(db.Model):
    __tablename__ = 'bookshelves'
    id           = db.Column(db.Integer, primary_key=True)
    name         = db.Column(db.String)
    description  = db.Column(db.String)
    books        = db.relationship('Book', backref='bookshelf', lazy='dynamic')

    def __repr__(self):
        return f'<BookShelf id={self.id} name={self.name}>'

    def to_dictionary(self):
        return {
            'id':          self.id,
            'name':        self.name,
            'description': self.description
        }

app.py ファイルはこうなってる(変更なし)。最後の import views は、ルーティングと対応する処理を定義したモジュールをインポートしている。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate


app = Flask(__name__)
app.config.from_pyfile('./bruschetta.conf')

db = SQLAlchemy(app)

import models

migrate = Migrate(app, db)

import views

この状態で flask db migrate を実行すれば、データベースをマイグレートするスクリプトを生成してくれる。実行するとこうなった(poetry をパッケージ管理に使ってるので poetry 経由だ)。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db migrate -m "create bookshelves table"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.

migrations/versions 以下にマイグレート用のスクリプトができるはずだけどできていない。

takatoh@apostrophe:bruschetta-feature-bookshelf$ ls migrations/versions
0bff8cd02ef3_create_tables.py           __pycache__
981c2baab82e_create_coverarts_table.py

もとからあった2つしかない。おかしいと思いつつも flask db upgrade を実行してみたけど、当然のようにエラーになった。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run 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 0bff8cd02ef3 -> 981c2baab82e, Create coverarts table
Traceback (most recent call last):
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1969, in _exec_single_context
(以下略)

いや、待て。マイグレート用のスクリプトが作られてないなら、データベースもアップグレードしないんだから、エラーもなく、何もせずに終了するはずでは?

そこで、データベースの中を覗いてみることにした。

takatoh@apostrophe:bruschetta-feature-bookshelf$ sqlite3 instance/bruschetta.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
alembic_version  categories       formats        
books            coverarts

テーブルが5つある。bookscategoriesformats は一番始めに作ったテーブル、coverarts はあとから追加したテーブル、そして alembic_version は Flask-Migrate が内部で使っている alembic というデータベースマイグレーション用のライブラリが利用するテーブルだ。

alembic_version に何が格納されているか見てみる(その前にヘッダを表示するようにしておく)。

sqlite> .headers on
sqlite> SELECT * FROM alembic_version;
version_num
0bff8cd02ef3

version_num というカラムが1つだけあって、0bff8cd02ef3 という値(文字列)が格納されている。この値はマイグレーション用スクリプトの識別子で、ファイル名の頭に付いているほか、ファイルの中にも Revision ID の値として書き込まれている。

この 0bff8cd02ef3 という値は、過去2回のマイグレーションのうちの最初の方の値だ。2回目のマイグレーション(coverarts テーブルを追加したとき)にこの値も書き換えられるはず、だと思うんだけどどういうわけかそうならなかったんじゃないか。だとすれば、これを書き換えてやればいいのかも。

sqlite> UPDATE alembic_version SET version_num="981c2baab82e" WHERE version_num="0bff8cd02ef3";
sqlite> SELECT * FROM alembic_version;
version_num
981c2baab82e

これで書き換わった。データベースを抜けて、flask db migrate を改めて実行する。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db migrate -m "create bookshelves table"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'bookshelves'
INFO  [alembic.autogenerate.compare] Detected added column 'books.bookshelf_id'
INFO  [alembic.autogenerate.compare] Detected added foreign key (bookshelf_id)(id) on table books
INFO  [alembic.autogenerate.compare] Detected added foreign key (coverart_id)(id) on table books
  Generating /home/takatoh/w/Bruschetta/bruschetta-feature-
  bookshelf/migrations/versions/3ccfddd40b69_create_bookshelves_table.py ...  done

おお、今度はうまくいったっぽい。実際、migrations/versions 以下に新しいファイルが生成されている。3ccfddd40b69_create_bookshelves_table.py がそれだ。

takatoh@apostrophe:bruschetta-feature-bookshelf$ ls migrations/versions
0bff8cd02ef3_create_tables.py
3ccfddd40b69_create_bookshelves_table.py
981c2baab82e_create_coverarts_table.py
__pycache__

では、flask db upgrade を実行してみよう。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run 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 981c2baab82e -> 3ccfddd40b69, create bookshelves table
Traceback (most recent call last):
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/bin/flask", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask/cli.py", line 1064, in main
    cli.main()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1078, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1434, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/decorators.py", line 33, in new_func
    return f(get_current_context(), *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask/cli.py", line 358, in decorator
    return __ctx.invoke(f, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask_migrate/cli.py", line 150, in upgrade
    _upgrade(directory, revision, sql, tag, x_arg)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask_migrate/__init__.py", line 111, in wrapped
    f(*args, **kwargs)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask_migrate/__init__.py", line 200, in upgrade
    command.upgrade(config, revision, sql=sql, tag=tag)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/command.py", line 403, in upgrade
    script.run_env()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/script/base.py", line 583, in run_env
    util.load_python_file(self.dir, "env.py")
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/util/pyfiles.py", line 95, in load_python_file
    module = load_module_py(module_id, path)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/util/pyfiles.py", line 113, in load_module_py
    spec.loader.exec_module(module)  # type: ignore
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/migrations/env.py", line 96, in <module>
    run_migrations_online()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/migrations/env.py", line 90, in run_migrations_online
    context.run_migrations()
  File "<string>", line 8, in run_migrations
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/runtime/environment.py", line 948, in run_migrations
    self.get_context().run_migrations(**kw)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/runtime/migration.py", line 627, in run_migrations
    step.migration_fn(**kw)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/migrations/versions/3ccfddd40b69_create_bookshelves_table.py", line 27, in upgrade
    with op.batch_alter_table('books', schema=None) as batch_op:
  File "/home/takatoh/.pyenv/versions/3.11.7/lib/python3.11/contextlib.py", line 144, in __exit__
    next(self.gen)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/operations/base.py", line 398, in batch_alter_table
    impl.flush()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/operations/batch.py", line 162, in flush
    fn(*arg, **kw)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/operations/batch.py", line 669, in add_constraint
    raise ValueError("Constraint must have a name")
ValueError: Constraint must have a name

おおう、またエラーだ。”Constrain must have a name” (制約には名前が必要)だと。ググってみると StackOverflow にズバリの回答を見つけた。

曰く、これは正常な動作で、なぜなら SQLite3 は ALTER table をサポートしてないから。解決法としては、SQLAlchemy のメタデータにすべてのタイプの制約の命名テンプレートを作成するのがいい(今後のためにも)、という。

そういうわけで、app.py をつぎのように書き換えた。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from sqlalchemy import MetaData


app = Flask(__name__)
app.config.from_pyfile('./bruschetta.conf')

convention = {
    'ix': 'ix_%(column_0_label)s',
    'uq': 'uq_%(table_name)s_%(column_0_name)s',
    'ck': 'ck_%(table_name)s_%(constraint_name)s',
    'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
    'pk': 'pk_%(table_name)s'
}
metadata = MetaData(naming_convention=convention)
db = SQLAlchemy(app, metadata=metadata)

import models

migrate = Migrate(app, db)

import views

そしてさっきのマイグレーションスクリプトを削除して、やり直す。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db migrate -m "create bookshelves table"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'books.bookshelf_id'
INFO  [alembic.autogenerate.compare] Detected added foreign key (bookshelf_id)(id) on table books
INFO  [alembic.autogenerate.compare] Detected added foreign key (coverart_id)(id) on table books
  Generating /home/takatoh/w/Bruschetta/bruschetta-feature-
  bookshelf/migrations/versions/1821fe006433_create_bookshelves_table.py ...  done
takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run 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 981c2baab82e -> 1821fe006433, create bookshelves table

今度はちゃんとデータベースをアップグレードできたようだ。データベースの中も見てみる。

takatoh@apostrophe:bruschetta-feature-bookshelf$ sqlite3 instance/bruschetta.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
alembic_version  bookshelves      coverarts      
books            categories       formats        
sqlite> .headers on
sqlite> SELECT * FROM alembic_version;
version_num
1821fe006433

bookshelves テーブルもできているし、alembic_version テーブルも更新されている。

これでやっと、WEBアプリの機能追加にかかれる。