1. 移行の背景
- python-prismaのサポート状況: Python版のPrismaクライアントは、Node.js版に比べてコミュニティ主導の側面が強く、公式のアップデート速度や将来的なメンテナンスへの不安がありました。
- パフォーマンスと透明性: ORM特有の隠蔽されたクエリによるオーバーヘッドを減らし、DBに直接発行されるSQLを開発者が完全にコントロールしたいという要求が高まりました。
2. Prisma vs mysql-connector:決定的な違い
「オブジェクト」から「タプル/辞書」へのダウングレード
Prismaを使っている間、「DBのレコード」を意識せず「Pythonのオブジェクト」としてデータを扱っていました。
-
Prisma:
plan.projects.projectとドットで繋ぐだけで、裏側のJOINを意識せずに型安全にアクセスできます。 -
mysql-connector: 実行結果は単なる
dict(辞書)のリストです。row["list"]と書く必要があり、タイポしても実行するまでエラーになりません。
このギャップを埋めるのが Pydanticの model_validate です。辞書を受け取ってモデルに変換する工程を挟むことで、生SQLでもPrismaと同等の型安全性を確保しました。
「自動階層化」 vs 「フラットな1次元データ」
ここが移行で最も工数がかかるポイントです。Prismaの include は、裏で複数のクエリを発行したり、結果を賢くマージして「ネストされた構造」を作ってくれます。
-
Prisma: 1つの計画に紐づく「3人のユーザー」は、最初から
plan.userという配列で入っています。 -
mysql-connector:
JOINすると、1つの計画に対して3行のデータが返ってきます(計画の情報が重複する)。
ポイント: そのままPydanticに流し込むと、同じ計画が3つ重複して表示されてしまいます。そのため、「IDをキーにして重複をまとめ、子テーブルを配列に詰め直す」ロジックの自作が必要不可欠です。
NULL(None)に対する「許容度」の差
移行中に最も苦しめられたのが ValidationError です。
-
Prismaの挙動: フィールドが
nullの場合、よしなにNoneを返し、モデル変換時もエラーになりにくい傾向があります。 -
mysql-connector + Pydantic: DBから
Noneが返ってきた際、Pydanticモデルがstr(必須)と定義されていると、即座にシステムが停止します。
解決策: str | None = "" のように、モデル側で「Nullが来たら空文字にする」というデフォルト値を明示することが、生SQL移行では標準戦略となります。
クエリ構築の「静的」 vs 「動的」
-
Prisma:
where: { status: 'DELETED' }のように、オブジェクトの組み合わせでクエリを組み立てます。 - mysql-connector: 文字列操作(f-string等)でSQLを組み立てます。
メリット: 文字列操作は一見原始的ですが、「特定の条件下でのみ複雑なサブクエリを差し込む」といった処理は、Prismaの独自の書き方を覚えるよりも、素のSQLを組み立てる方が圧倒的に自由度が高く、パフォーマンスチューニングも容易になります。
3. ただSQLを書くだけでは動かない
Prismaのクエリをそのまま SELECT * FROM ... に置き換えただけでは、フロントエンドはエラーを吐き、画面は真っ白になります。そこにはSQLだけでは解決できない「アプリケーション層のロジック」が必要でした。
「1:N」関係がもたらすデータの重複問題
Prismaの include は、裏側で「親」と「子」を別々に引くか、マージした状態で「階層化されたオブジェクト」として返してくれます。しかし、生SQLの LEFT JOIN は、結合した数だけ「親」のレコードを重複させます。
- 現象: 1つの「企画(Plan)」に3人の「担当者(Staff)」が紐づいている場合、SQLは3行を返します。
- 問題: これをそのままPydanticに渡すと、画面上には「同じIDの企画」が3つ並んでしまいます。
-
解決策(階層化ロジックの自作):
IDをキーにした辞書を作成し、ループの中で「すでにそのIDが存在すれば子要素(Staff)だけを配列に追加、なければ親レコードを新規作成」という、Prismaが自動でやっていた処理を自前で実装(_nest_resultsメソッド)する必要があります。
Pydantic V2 の「厳格な型チェック」
Prisma時代は「型推論」に甘えていた部分がありましたが、生SQL移行で model_validate() を使うようになると、Pydanticが立ちはだかります。
-
ハマりポイント:
SQLでLEFT JOINした子テーブルの値が空(Null)だった場合、PythonにはNoneとして渡ります。 -
モデル定義:
staff_name: str(必須) -
実際のデータ:
None -
結果:
[type=string_type, input_value=None]というバリデーションエラー。 -
解決策: すべての任意項目に
| None = ""という「Nullなら空文字にする」デフォルト値を設定。Prismaが裏でよしなにやっていた「型変換」を、モデル定義で明示的に制御する設計への変更が必要でした。
カラム名のエイリアスと「マッピング」の不一致
PrismaなどのORMを使っているときは、schema.prismaを修正すれば大丈夫でした。しかし、生SQL(mysql-connector)に移行すると、定義が以下の3箇所に分散してしまいます。
-
DBの物理カラム名(例:emp_no):
データベース上の実際の名前。 -
SQLのエイリアス(例:SELECT emp_no AS staff_id)
ここで開発者が「名前の変換」を自由に行えてしまう。 -
Pydanticモデルのプロパティ名(例:staff_id: str)
プログラムが最終的に期待する名前。
-
ミスが発生しやすい理由:
この「2」と「3」が、それぞれ別々のファイルで人間によって書かれるため、タイポ(打ち間違い)や、片方だけ修正してもう片方を忘れるといったミスが起きやすくなります。 - SQL側のカラム名(エイリアス)と、モデル側のフィールド名を完全に一致させる、あるいは
Field(alias="...")を使って橋渡しをするという、DBとコードの「結合部分」を再定義する作業が発生します。
4. 移行を成功させる実装テクニック
Prismaから mysql-connector への移行を「ただの動くコード」ではなく「メンテナンス性の高いコード」に昇華させるために以下を意識しました。
共通ベースクエリによる「取得漏れ」の防止
生SQLの最大の敵は、複数のメソッドに似たようなSQLが散らばり、片方の修正を忘れてバグ(データが取得できない、特定のカラムがNoneになる等)が発生することです。
_get_base_query() というプライベートメソッドに、SELECT と LEFT JOIN の基本形を定義します。
def _get_base_query(self) -> str:
# 複雑なJOINやCASE文を一箇所に集約
return """
SELECT DISTINCT
p.project_id,
p.title,
s.staff_name AS staff_name,
s.employee_no AS staff_no,
d.device_code AS device_code,
CASE WHEN p.status = 'DRAFT' THEN TRUE ELSE FALSE END AS is_draft
FROM projects p
LEFT JOIN project_staff s ON p.project_id = s.project_id
LEFT JOIN project_devices pd ON p.project_id = pd.project_id
LEFT JOIN devices d ON pd.device_id = d.device_id
"""
このように定義しておくことで、find_by_id でも search_by_params でも、常に同じ結合ルート(例えばデバイス名の取得パスなど)が保証されます。
Pydanticモデルでの「防御的プログラミング」
生SQL移行でもっとも厄介なのは、DBの NULL が原因でAPI全体が500エラーになることです。これをモデル側で吸収します。
テクニック: デフォルト値と型ヒントの組み合わせ。
class StaffModel(BaseModel):
# 必須項目(データがないとエラーになる)
employee_no: str
# 任意項目(Noneが来ても自動で空文字に変換される)
staff_name: str | None = ""
class ProjectModel(BaseModel):
project_id: str
title: str = ""
staffs: list[StaffModel] = [] # 空リストをデフォルトにしておくと安心
このように | None = "" や [] を設定することで、SQLの JOIN 結果が0件であっても、フロントエンドには「データがない状態の正常なJSON」が返り、fetch failed を防ぐことができます。
1:N 構造を再構築する「階層化ロジック」
SQLの結果はフラットなため、同一IDの行が複数返ってきます。これをPydanticが期待するリスト構造(staff: [StaffModel])に変換する共通処理を用意します。
テクニック: _nest_results() メソッドで、辞書のキーをまとめ上げます。
def _nest_results(self, rows: list[dict]) -> list[dict]:
nested_data = {}
for row in rows:
p_id = row["project_id"]
if p_id not in nested_data:
# 親要素の初期化
nested_data[p_id] = {**row, "staffs": [], "devices": []}
# 子要素(スタッフ)の重複排除と追加
if row.get("staff_no"):
staff_item = {"staff_no": row["staff_no"], "staff_name": row["staff_name"]}
if staff_item not in nested_data[p_id]["staffs"]:
nested_data[p_id]["staffs"].append(staff_item)
# 子要素(デバイス)の追加
if row.get("device_code"):
device_item = {"device_code": row["device_code"]}
if device_item not in nested_data[p_id]["devices"]:
nested_data[p_id]["devices"].append(device_item)
return list(nested_data.values())
この「辞書を捏ねる」処理をリポジトリ内に一段挟むことで、Prismaの include と同等の使い勝手を実現できます。
5. 移行してわかった生SQLの3つの良いところ
デバッグの高速化
PrismaのようなORMを使っていると、複雑なクエリが内部でどう発行されているかはブラックボックスになりがちです。
- Prisma: エラーが起きても、内部で生成された複雑なSQLをログから探し出し、解析するのに時間がかかる。
- 生SQL: 実行されているのは、自分が書いたそのSQLそのものです。
- メリット: ログに出力されたSQLをそのままコピーし、MySQL WorkbenchやDBeaverなどのツールに貼り付ければ、1秒でデータを確認できます。なぜこのデバイス情報が出ないのか?」という原因究明のスピードが劇的に向上しました。
実行計画を意識したパフォーマンスチューニングの自由度
Prismaの include は便利ですが、時として非効率なクエリ(N+1問題や、巨大なテーブル同士の暗黙的なフルスキャン)を発行することがあります。
-
生SQL: インデックスを効かせるための
HINT句を使ったり、特定のJOINをEXISTS句に書き換えたりといった、高度な最適化が自由自在です。 -
メリット: 今回の移行でも、特定の検索条件でレスポンスが重かった箇所を、サブクエリから
INNER JOINへ、あるいはその逆へと、ミリ秒単位のパフォーマンス改善をダイレクトに行えるようになりました。
インフラとライブラリへの依存からの脱却
これが最も長期的に効いてくるメリットです。
- Prismaの制約: Python版Prismaのように、特定の言語・特定のライブラリのメンテナンス状況にプロジェクトの寿命が左右されるリスクがあります。
-
生SQLの普遍性:
SELECT,FROM,WHEREというSQLの知識は、この先10年経っても、あるいは言語をPythonからGoやRustに変えたとしても通用します。 - メリット: ライブラリのメジャーアップデートによる「破壊的変更」に怯える必要がなくなり、プロジェクトの持続可能性が大幅に高まりました。