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アプリの機能追加にかかれる。

Ruby:HTTPクライアントライブラリを http gem にのりかえた

すごーく久しぶりに、Ruby で HTTP アクセスするってことをやった。使い慣れた httpclient を使ったんだけど、こんなエラーが出た。

C:/Ruby32-x64/lib/ruby/gems/3.2.0/gems/httpclient-2.8.3/lib/httpclient/ssl_socket.rb:103:in `connect': SSL_connect returned=1 errno=0 peeraddr=[2606:4700:3032::ac43:8791]:443 state=error: certificate verify failed (certificate has expired) (OpenSSL::SSL::SSLError)
(以下略)

SSLのエラーだ。

ググって調べてみると、httpclient gem は信頼できる証明書を独自に持っていてそれがもうメンテされていない、ということが解った。

上の記事では、システムのデフォルトの証明書を利用することで回避する方法も書かれている。けど、RubyGems.org をみると httpclient gem の最後のリリースは 2016/9 で、これはもう他のライブラリにのりかえるべきだろう。

で、さらにググって調べたところ、http という gem の評判がよさげに見えたのでこれを使ってみることにした。こんな名前よく空いてたな。

使い方は GitHub の Wiki にまとまっている。

httpclient みたいにインスタンスを作る必要がなくて 、require "http" したら HTTP モジュールの関数が使える。例えば GET の場合はこう。

irb(main):001:0> require "http"
=> true
irb(main):002:0> res = HTTP.get("https://blog.panicblanket.com")
=> #<HTTP::Response/1.1 200 OK {"Date"=>"Sat, 06 Jan 2024 20:51:15 GMT", "Content-Type"=>"text/...

レスポンスのコードは #code 、ボディは #to_s で取得できる。

irb(main):003:0> res.code
=> 200
irb(main):004:0> res.to_s
=> "<!DOCTYPE html>\n\n<html lang=\"ja\">\n\n\t<head>\n\n\t\t<meta http-equiv=\"content-type\" content=\"text/html\" charset=\"UTF-8\" />\n\t\t<meta 
(以下略)

こんな感じ。

GETPOSTPUTDELETE などの HTTP メソッドの他にも WEBDAV なんかもサポートしてる。使いやすそうだ。

Dockerコンテナ上のMediaWikiをアップグレードする

つい先日(4日前)に最新版のDocker公式のコンテナイメージ 1.41.0 がリリースされているのを見つけたので、ローカルネットワークで運用している MediaWiki をアップグレードした。これまでのバージョンは 1.35.0。

まずは、データベースのバックアップをとる。データベースもMariaDBのDockerコンテナだ。

takatoh@unclemeat:~$ docker exec -it mywiki-db mysqldump -hlocalhost -P3306 -uroot -prootpass mywiki > mywiki.sql

mywiki-db がデータベースのコンテナ。このコンテナの中で mysqldump コマンドを実行して、mywiki.sql ファイルに書き込んでいる。mysqldump-h オプションはホスト、-P はポート、-u はユーザ、-p はパスワード。-p と引数のあいだに空白を入れるとどういうわけかうまく行かない。

バックアップをとったら、いったんコンテナを止める。

takatoh@unclemeat:~$ cd docker-configuration
takatoh@unclemeat:~/docker-configuration$ docker compose down

docker-compose.yml ファイルを書き換える。MediaWiki イメージのバージョンを1.35.0から1.41.0に書き換えるだけだ。

version: "3"

services:

  mywiki:
    image: mediawiki:1.41.0
    container_name: mywiki
    restart: always
    depends_on:
      - mywiki-db
    volumes:
      - "${CONTAINER_ENV_DIR}/mywiki/images:/var/www/html/images"
      - "${CONTAINER_ENV_DIR}/mywiki/LocalSettings.php:/var/www/html/LocalSettings.php"
      - "${CONTAINER_ENV_DIR}/mywiki/php.ini-production:/usr/local/etc/php/php.ini"
      - "${CONTAINER_ENV_DIR}/mywiki/extensions/YouTube:/var/www/html/extensions/YouTube"
    ports:
      - 9090:80

  mywiki-db:
    image: mariadb:10.5.6-focal
    container_name: mywiki-db
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: mywiki
      MYSQL_USER: wikiuser
      MYSQL_PASSWORD: wikipass
    volumes:
      - "${CONTAINER_ENV_DIR}/mywiki/db:/var/lib/mysql"
    ports:
      - 8836:3306

書き換えが終わったら、コンテナを起動する。

takatoh@unclemeat:~/docker-configuration$ docker compose up -d

問題なく起動したけど、ブラウザでアクセスするとエラーが出ていた。データベースのテーブルにカラムが見つからないらしい。MediaWiki のアップブレードでデータベースのスキーマも更新する必要があるようだ。↓このページが参考になった。

MediaWiki のディレクトリで更新スクリプトを実行すればいいようだ。MediaWiki は Dockerコンテナで動いているので、コンテナの中で実行する。

takatoh@unclemeat:~/docker-configuration$ docker exec -it mywiki bash
root@a423a4fe2c67:/var/www/html# php maintenance/run.php update.php
MediaWiki 1.41.0 Updater

Your composer.lock file is up to date with current dependencies!
Going to run database updates for bsl_wiki
Depending on the size of your database this may take a while!
Abort with control-c in the next five seconds (skip this countdown with --quick) ...0
...collations up-to-date.
(以下略)

無事、更新スクリプトが終了したら、念のためコンテナを再起動する。で、改めてブラウザでアクセスすると、今度はエラーなくページが表示された。

これで完了。