Prisma + Cloudflare D1 ( SQLite ) で日付の扱いにハマったのでスキーマ設計を見直す
色々な技術を触ってみようということで、Cloudflare D1 を触っていたところ日付の扱いにハマりました。
以下のような技術構成で REST API を作っていた時の話です。
Prisma + Cloudflare D1 を利用していてハマったこと
Cloudflare D1 の実態は SQLite なので、Prisma でスキーマを定義する場合、 SQLite の connector を使います。
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
DATABASE_URL="file:../.wrangler/state/v3/d1/<database_id>/db.sqlite"
この技術構成の環境構築について、この記事では細かい話はしません。
私は mizchi さんの記事を参考にしました。
事象
prisma migrate dev
を実行すると毎回、テーブルを作り直すようなmigration.sql
ファイルが生成される。
原因
Prisma のスキーマには autoincrement()
のような functions を使うことができますね。
そのうち dbgenrated() で SQLite の unixepoch 関数を使っていたことが原因でした。
model User {
createdAt DateTime @default(dbgenerated("(unixepoch())"))
}
解決方法
Prisma の functions のうちnow()
を使うことで解決しました。
model User {
createdAt DateTime @default(now())
}
Prisma の issues にも似たような事象がありました。まだ解決していない模様。
マイグレーションの挙動を調査する
prisma migrate dev
で生成されるmigration.sql
を観察してみます。
@default(now())
を利用した場合
初回のマイグレーションファイル
-- 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 許容で追加
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email" TEXT;
@default(dbgenerated("(unixepoch())"))
を利用した場合
初回のマイグレーションファイル
-- 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 許容で追加
-- 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 のデータ型にマッピングされるのかは公式ドキュメントを見てみましょう。
Prisma のDateTime
は SQLite のNUMERIC
にマッピングされるとのこと。
dbgenerated()
関数は何ができるのか
そもそも Prisma のRepresents default values that cannot be expressed in the Prisma schema (such as random()).
ということで、 Prisma のスキーマでは表現できないデフォルト値を生成するために使われます。
今回dbgenerated()
関数を使って SQLite のunixepoch()
関数を利用したかったのは、NUMERIC
のデータ型に対応するためでした。
しかし上記の通り毎回のマイグレーションでテーブルが再作成されてしまうので諦める...
@default(now())
を使うことが最適なのか?
now()
関数は現在時刻を文字列として生成します。
ここまでの調査結果を考慮すると、NUMERIC
のデータ型のカラムに対して文字列の日付を入れることは良くないよな、と思います。
かといって、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
ディレクティブが利用できません。
以下のようなスキーマを想定した場合
model User {
id Int @id @default(autoincrement())
name String
email String
createdAt DateTime @default(now())
updatedAt DateTime
}
アプリケーションからレコードを作成・更新する際に updatedAt の値を渡す必要があります。
prisma-kysely を使っている場合、以下のようにCURRENT_TIMESTAMP
を使えばよさそうです。
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 のサーバーで運営しており、以下のリンクから無料で参加できます。コミュニティ内では以下のような投稿・活動がされます!
Discussion