🔆

Prisma + Cloudflare D1 ( SQLite ) で日付の扱いにハマったのでスキーマ設計を見直す

2024/02/21に公開

色々な技術を触ってみようということで、Cloudflare D1 を触っていたところ日付の扱いにハマりました。
以下のような技術構成で REST API を作っていた時の話です。

Prisma + Cloudflare D1 を利用していてハマったこと

Cloudflare D1 の実態は SQLite なので、Prisma でスキーマを定義する場合、 SQLite の connector を使います。

prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
prisma/.env
DATABASE_URL="file:../.wrangler/state/v3/d1/<database_id>/db.sqlite"

この技術構成の環境構築について、この記事では細かい話はしません。
私は mizchi さんの記事を参考にしました。
https://zenn.dev/mizchi/articles/d1-prisma-kysely

事象

prisma migrate devを実行すると毎回、テーブルを作り直すようなmigration.sqlファイルが生成される。

原因

Prisma のスキーマには autoincrement()のような functions を使うことができますね。
そのうち dbgenrated() で SQLite の unixepoch 関数を使っていたことが原因でした。

schema.prisma
model User {
  createdAt DateTime @default(dbgenerated("(unixepoch())"))
}

解決方法

Prisma の functions のうちnow()を使うことで解決しました。

schema.prisma
model User {
  createdAt DateTime @default(now())
}

Prisma の issues にも似たような事象がありました。まだ解決していない模様。
https://github.com/prisma/prisma/issues/18659

マイグレーションの挙動を調査する

prisma migrate devで生成されるmigration.sqlを観察してみます。

@default(now())を利用した場合

初回のマイグレーションファイル

now_v1/migration.sql
-- CreateTable
CREATE TABLE "User" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "name" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL
);

email カラムを NULL 許容で追加

now_v2/migration.sql
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email" TEXT;

@default(dbgenerated("(unixepoch())"))を利用した場合

初回のマイグレーションファイル

unixepoch_v1/migration.sql
-- CreateTable
CREATE TABLE "User" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "name" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT (unixepoch()),
    "updatedAt" DATETIME NOT NULL
);

email カラムを NULL 許容で追加

unixepoch_v2/migration.sql
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "name" TEXT NOT NULL,
    "email" TEXT,
    "createdAt" DATETIME NOT NULL DEFAULT (unixepoch()),
    "updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("createdAt", "id", "name", "updatedAt") SELECT "createdAt", "id", "name", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

new_UserというテーブルをCREATEしてしまう。そしてUserテーブルはDROPしている...
なぜこの挙動になるのか原因は把握できていない。

Prisma + SQLite の設計を見直す

日付を扱う場合、Prisma の Scalar Type は DateTime を使うことが一般的です。
Prisma のスキーマで指定された Scalar Type がどのように SQLite のデータ型にマッピングされるのかは公式ドキュメントを見てみましょう。
https://www.prisma.io/docs/orm/overview/databases/sqlite#native-type-mapping-from-prisma-orm-to-sqlite
Prisma のDateTimeは SQLite のNUMERICにマッピングされるとのこと。

そもそも Prisma のdbgenerated()関数は何ができるのか

Represents default values that cannot be expressed in the Prisma schema (such as random()).

ということで、 Prisma のスキーマでは表現できないデフォルト値を生成するために使われます。
今回dbgenerated()関数を使って SQLite のunixepoch()関数を利用したかったのは、NUMERICのデータ型に対応するためでした。
しかし上記の通り毎回のマイグレーションでテーブルが再作成されてしまうので諦める...

@default(now())を使うことが最適なのか?

now()関数は現在時刻を文字列として生成します。
ここまでの調査結果を考慮すると、NUMERICのデータ型のカラムに対して文字列の日付を入れることは良くないよな、と思います。
かといって、Prisma のスキーマで以下のように書くことはできません(エラーが出て怒られます)

prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  createdAt String   @default(now()) // Error parsing attribute "@default": The function `now()` cannot be used on fields of type `String`.
  updatedAt String
}

ということでこの設計が最適ではないと考えています。Prisma, SQLite, Cloudflare D1 についてさらに調査したら記事を更新しようと思います。

その他、 Prisma で SQLite のスキーマ設計をする際に気をつけること

SQLite では@updatedAtディレクティブが利用できません。

以下のようなスキーマを想定した場合

prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String
  createdAt DateTime @default(now())
  updatedAt DateTime
}

アプリケーションからレコードを作成・更新する際に updatedAt の値を渡す必要があります。
prisma-kysely を使っている場合、以下のようにCURRENT_TIMESTAMPを使えばよさそうです。

repository/user.ts
import { sql } from 'kysely'
import { D1Database } from '@cloudflare/workers-types'
import { client } from '../db'

const createUser = (database: D1Database, user: { name: string, email: string }) => {
  await client(database)
    .insertInto('User')
    .values({ ...user, updatedAt: sql`CURRENT_TIMESTAMP`})
    .execute()
}

まとめ

Prisma でスキーマ定義する際に Cloudflare D1 の内部実装で使われている SQLite のデータ型や関数でハマった話をしました。
最適解が出ていないので、もう少し調べてみます。

最後に、toraco株式会社では2024年11月1日にエンジニア向けのコミュニティを立ち上げました。
Discord のサーバーで運営しており、以下のリンクから無料で参加できます。コミュニティ内では以下のような投稿・活動がされます!

https://discord.gg/pxfMjDfsge

  • もくもく会・作業ラジオ・雑談部屋などオンライン上での交流
  • オフラインイベントの案内
  • 代表の稲垣(トラハック)が公開するコンテンツの説明・質問回答
  • toraco株式会社からの副業や案件の紹介
  • フロントエンド関連技術の情報共有および議論
  • 生成AI関連技術のキャッチアップ
  • その他、技術領域にこだわらない情報共有および議論
toraco株式会社のテックブログ

Discussion