Flask-SQLAlchemy で ER 図を出力する


## はじめに

ある Flask プロジェクトで、データベース (Flask-SQLAlchemy) の ER 図を作成しようとしたところ、設定準備に意外と手間をかけさせられたため、同じ境遇の方や未来の自分の参考になればと思い忘備録を残した。

## 前提

- python: 3.12.0
- flask: 3.0.2
- flask-sqlalchemy: 3.1.1

## 設定

### グラフ描画ライブラリの追加

https://github.com/graphp/graphviz

```sh
brew install graphviz
```

### graphviz の python インターフェースパッケージの追加

https://github.com/pygraphviz/pygraphviz

以下のコマンドを実行し、先ほど追加した `graphviz` を python 環境で使用できるようにするパッケージをインストールする。

```sh
python3 -m pip install --use-pep517 \
--config-settings="--global-option=build_ext" \
--config-settings="--global-option=-I<graphviz_dir>/include/" \
--config-settings="--global-option=-L<graphviz_dir>/lib/" \
pygraphviz -t <install_dir>
```

オプションで指定するパスに必要な情報ついて解説する。

- `graphviz_dir`: graphviz がインストールされているディレクトリ。以下のコマンドで確認する。

  ```sh
  ls -la $(brew --prefix graphviz)

  /opt/homebrew/opt/graphviz@ -> ../Cellar/graphviz/11.0.0
  ```

  シンボリックリンクの `../Cellar/graphviz/11.0.0` が該当箇所である。必要に応じて読み替える必要あり。
  また、絶対パスで指定する必要があり: e.g. `/opt/homebrew/Cellar/graphviz/11.0.0`

- `install_dir`: `pygraphviz` をインストールしたい場所。
    以下のコマンドで出力される結果を確認する。

  ```sh
  poetry env info

  Virtualenv
  Python:         3.12.0
  Implementation: CPython
  Path:           /<path_to_user_dir>/Library/Caches/pypoetry/virtualenvs/<prj_dir>-<hash>-py3.12 <-----コレ
  Executable:     /<path_to_user_dir>/Library/Caches/pypoetry/virtualenvs/<prj_dir>-<hash>-py3.12/bin/python
  Valid:          True
  ...
  ```

  上記の `Path` の末尾に `/lib/python3.12/site-packages` を append したものを指定する。

最終的には、このようなコマンドを実行することになる。

```sh
python3 -m pip install --use-pep517 \
--config-settings="--global-option=build_ext" \
--config-settings="--global-option=-I/opt/homebrew/Cellar/graphviz/11.0.0/include/" \
--config-settings="--global-option=-L/opt/homebrew/Cellar/graphviz/11.0.0/lib/" \
pygraphviz -t /<path_to_user_dir>/Library/Caches/pypoetry/virtualenvs/<prj_dir>-<hash>-py3.12/lib/python3.12/site-packages
```

### ER 図ジェネレーターパッケージの追加

https://github.com/maurerle/eralchemy2

[こちら](#graphviz-の-python-インターフェースパッケージの追加) と同じ手順で、`eralchemy2` というパッケージをインストールする。

```sh
python3 -m pip install --use-pep517 \
--config-settings="--global-option=build_ext" \
--config-settings="--global-option=-I/opt/homebrew/Cellar/graphviz/11.0.0/include/" \
--config-settings="--global-option=-L/opt/homebrew/Cellar/graphviz/11.0.0/lib/" \
eralchemy2 -t /<path_to_user_dir>/Library/Caches/pypoetry/virtualenvs/<prj_dir>-<hash>-py3.12/lib/python3.12/site-packages
```

## ER 図の生成

**※データベースサーバーをあらかじめ起動しておく必要がある。**

以下のようなコードで Flask アプリが動いているとする。


```python:commands.py
import click
from eralchemy2 import render_er
from flask import current_app
from flask.cli import with_appcontext

def generate_db_schema():
    try:
        render_er("postgresql://admin:password@localhost:5432/test_db", "db_schema.png")

    except Exception as e:
        print(f"エラー発生: {e}")

@click.command("generate-db-schema")
@with_appcontext
def generate_db_schema_command():
    """データベースのスキーマを生成します。"""
    generate_db_schema()
```

`generate_db_schema()` が ER 図生成の実態である。
`render_er` は、**第一引数にデータベースの URI を、第二引数に出力ファイル名をとる。**

この関数を CLI から呼び出されるように、下記のように app.py に登録する。

```python:app.py
from flask import Flask

def create_app():
    app = Flask(__name__)

    # コマンドの追加
    from .commands import (
        generate_db_schema_command,
    )

    app.cli.add_command(generate_db_schema_command)

    return app
```

また、出力対象となるデータベースのスキーマの定義はこんな感じである。

```python:models.py
from datetime import datetime
from enum import Enum

from .database import db

class SecurityLevel(Enum):
    PUBLIC = "public"
    INTERNAL_ONLY = "internal_only"
    INTERNAL_RESTRICTED = "internal_restricted"

class Project(db.Model):
    __tablename__ = "projects"
    id = db.Column(db.Integer, primary_key=True)
    client_name = db.Column(db.String, nullable=False)
    title = db.Column(db.String, nullable=False)
    uploaded_date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
    updated_date = db.Column(
        db.Date, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
    )
    security_level = db.Column(db.Enum(SecurityLevel), nullable=False)
    usecase_en = db.Column(db.Text)
    usecase_ja = db.Column(db.Text)
    one_pager_link = db.Column(db.String)
    ended_date = db.Column(db.Date)
    industry_id = db.Column(db.Integer, db.ForeignKey("industries.id"))
    squad_id = db.Column(db.Integer, db.ForeignKey("squads.id"))
    product_id = db.Column(db.Integer, db.ForeignKey("products.id"))
    industry = db.relationship("Industry", back_populates="projects")
    product = db.relationship("Product", back_populates="projects")
    squad = db.relationship("Squad", back_populates="projects")

class Product(db.Model):
    __tablename__ = "products"
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String, nullable=False)
    projects = db.relationship("Project", back_populates="product", lazy="dynamic")

class Industry(db.Model):
    __tablename__ = "industries"
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String, nullable=False)
    projects = db.relationship("Project", back_populates="industry", lazy="dynamic")

class Squad(db.Model):
    __tablename__ = "squads"
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String, nullable=False)
    contact_email = db.Column(db.String)
    contact_name = db.Column(db.String)
    is_active = db.Column(db.Boolean, default=True)
    projects = db.relationship("Project", back_populates="squad", lazy="dynamic")

```

最後に、このコマンドで出力が実行される。

```sh
flask generate-db-schema
```

生成に成功すると、`db_schema.png` というファイルが出力される。

![スクリーンショット 2024-07-12 3.04.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/06b027d2-ec59-7b1a-6427-93ba61556ca5.png)