Prisma Client Python で JSON 型を扱うときの Dirty Hack

[Prisma ORM](https://www.prisma.io/orm) を Python からも利用したくて最近よく [Prisma Client Python](https://prisma-client-py.readthedocs.io/en/stable/) を使っている。普通に利用する分には何も問題はなく、TypeScript 版 Prisma Client に倣ったインタフェースは非常によくできていてかなり好きなのだけれど、ただ一点「**JSON 型が扱いづらい**」という問題だけが悩ましい。そもそも RDB で非構造データ型を多用するなという話ではあるので、妙なことをしようとして妙なところでハマっているという様相である。

この問題のスマートな解決策は未だ見つけられていないのだけれど、「とりあえずこうすれば動く」は見つけられたのでまとめておく。

**(2025.05 追記)** このあたりのコードを Dev Containers 上で試すためのリポジトリを公開した

https://github.com/hoto17296/prisma-cli-py-json-study

## 環境
- Prisma 5.17.0
- Python 3.12
- Prisma Client Python 0.15.0
- Pyright
- PostgreSQL 15

## 例
こういうスキーマでテーブルを作成したとする。

``` prisma:prisma.schema
model Log {
  id   Int  @id @default(autoincrement())
  meta Json
}
```

## 問題1: `SELECT` 結果の型が合わない
``` python
from prisma.models import Log

log = await Log.prisma().find_first()

log.meta  # 実態は dict 型だが、型チェッカーは prisma.fields.Json 型と認識している
```

`find` などのメソッドで取得したデータを見ると `dict` 型が入っており、既に parse されていることがわかる。しかし型チェッカーは `str` を継承した `prisma.fields.Json` 型であると認識してしまう。[^basejson]

[^basejson]: https://github.com/RobertCraigie/prisma-client-py/blob/v0.15.0/src/prisma/_fields.py#L29

型チェッカーに実態と合った型を認識させるためには、わざわざ cast をする必要がある。

``` python
from typing import cast

meta = cast(dict, log.meta)  # dict[Unknown, Unknown] 型
```

ただし `dict` だと型がゆるふわ過ぎるので、実際には `TypedDict` を使うことが多い。

``` python
from typing import NotRequired, TypedDict


class Meta(TypedDict):
    count: NotRequired[int]
```

``` python
from typing import cast

meta = cast(Meta, log.meta)
```

## 問題2: INSERT / UPDATE 時には Json 型が要求される
``` python
meta: Meta = {"count": 123}

await Log.prisma().create(data={"meta": meta})
#=> prisma.errors.DataError:
#=> Invalid argument type. `meta` should be of any of the following types: `Json`
```

Json 型フィールドを指定して INSERT / UPDATE したいとき、値を dump せずにそのまま指定することはできない。上記の問題と似ているが、ここでも `prisma.fields.Json` 型が期待される。

``` python
from prisma.fields import Json

meta: Meta = {"count": 123}

await Log.prisma().create(data={"meta": Json(meta)})  # 動くが、型エラー
```

`prisma.fields.Json` 型にしてやれば動くには動くが、<del>型チェッカーは `prisma.fields.Json` のコンストラクタに `str` 型を期待するので、</del>これだけだと型エラーが出てしまって気持ち悪い。

**(2025.05 追記)** `prisma.fields.Json` 型の第一引数は `Serializable` であって、無理に文字列にする必要はなかった。ただの `dict` なら上記コードでも問題なく通る。`TypeDict` の場合は `Serializable` でないと見なされて型エラーとなるので、その場合は `dict` に cast してやればよい。

``` python
from typing import cast
from prisma.fields import Json

meta: Meta = {"count": 123}

await Log.prisma().create(data={"meta": Json(cast(dict, meta))})
```

## 問題3: Optional な JSON フィールドに NULL を指定できない
``` prisma:prisma.schema
model Log {
  id   Int   @id @default(autoincrement())
  meta Json?
}
```

Json 型のフィールドが Optional (Nullable) の場合で、かつ何らかの理由で NULL を明示的に指定したい場合、これがうまくいかない。「`IS NULL` でフィルタリングしたい場合」「明示的に NULL を INSERT / UPDATE したい場合」どちらもエラーが出てしまう。

``` python
await Log.prisma().create(data={"meta": None})
#=> prisma.errors.MissingRequiredValueError:
#=> `data.meta`: A value is required but not set
```

この問題は既に Issue が立っていて v0.15.0 で解決されたように見える[^pr1000] のだけど、自分が動かしてみた限りでは問題が再現してしまう。困った。

[^pr1000]: https://github.com/RobertCraigie/prisma-client-py/pull/1000

この問題は良い解決策が本当に無くて、最終手段として `query_raw` を使うことを余儀なくされている。とてもつらい。

TypeScript の Prisma Client では、プリミティブな `undefined` `null` および Prisma 側で提供されている `DbNull` `JsonNull` を使うことで様々なケースに対応できる。[^null] しかし Python においては `undefined` `null` に相当する値がいずれも `None` で区別できず、かつ Prisma Client Python は `DbNull` などをサポートしていないため、すべて `None` で表現されてしまうことが本質的な課題である気がする。

[^null]: https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/working-with-json-fields#using-null-values

「そもそも Optional な JSON 型などというややこしいフィールドを使っている時点で設計が悪い」？ それはそう...

## おわり
こんな話をすると「Prisma Client Python ってヤバいんちゃう」って思われそうだけど、JSON 関連以外は全く問題ないっていうか本当に最高の体験なんや... ぜひ触ってみて欲しい