ORM 便利ですよね。最近の JavaScript 界隈では Prisma の勢いが日々強まっているのではないでしょうか。
今回は既存のシステムのある機能を Drizzle ORM で書き直した時に遭遇した落とし穴について紹介します。
Drizzle ORM
Drizzle ORM は外部の DB クライアントを呼び出すことで環境差異を吸収しています。
MySQL クライアントには node-mysql2 が推奨されており、Getting Started に沿って設定するだけで導入できました。
既存のシステムでは内部の ID として BIGINT 型を使用しています。
Drizzle ORM - MySQL column types
もちろん Drizzle ORM でもサポートしているので、これをマッピングします。
IDたちは丸められた
さて、Getting Started に沿って立ち上げた手元の開発環境(local)のユーザーでは問題なく動いていました。 しかし、検証用のリモート開発環境(devel)のユーザーではなぜか正常に動作しません。
実際に devel の DB に書き込まれていたレコードを紐解いていくと面白いことがわかりました。
devel の検証で使用したユーザーの ID は 2694793357167644579 となっており、関連テーブルには 2694793357167644672として記録されています。
値が変わっていますね。
よく見ると Number.MAX_SAFE_INTEGER (9007199254740991) よりも大きいので、数値として評価する際に精度が落ちたのではないでしょうか?
> BigInt(Number("2694793357167644579"))
« 2694793357167644672n
元となるユーザー ID から、変化後の関連テーブルに記録された ID が導出できました。 やはり数値の扱いが怪しそうです。
BIGINT型の扱い
怪しそうな部分に見当がついたので、Drizzle ORM と node-mysql2 の振る舞いについて見ていきます。
Drizzle ORM が使っている MySQL クライアント node-mysql2 の設定項目を見ると、supportBigNumbers と bigNumberStrings があることがわかりました。
それぞれの項目の説明は以下の通り
- BIGINT を扱うときは
supportBigNumbersを有効にすること - 常に文字列として扱いたい場合は
bigNumberStringsを有効にすること supportBigNumbersが有効かつbigNumberStringsが無効の場合は Number に収まらない場合だけ文字列として返します
supportBigNumbersが無効の時はそのまま数値として返すので、精度が落ちるということみたいですね。
なので、今回は supportBigNumbers を有効にしましょう。
ちなみに、Drizzle ORM 側のレイヤーでは文字列で受け取ることを期待しており、それを BigInt に変換してアプリケーションへ返しているようです。
drizzle-orm/drizzle-orm/src/mysql-core/columns/bigint.ts at main · drizzle-team/drizzle-orm · GitHub
override mapFromDriverValue(value: string): bigint { return BigInt(value); }
Drizzle ORM が文字列を期待しているので、bigNumberStrings も有効にしておきましょう。
import { drizzle } from "drizzle-orm/mysql2"; import * as mysql from "mysql2/promise"; const connection = mysql.createPool({ ... supportBigNumbers: true, bigNumberStrings: true, }); export const db = drizzle(connection, {
drizzle-orm/mysql2 のケア
DSN だけ渡した場合は勝手に supportBigNumbers を有効にしてくれるみたいですが、上記のように自前でクライアントを定義した場合は忘れずに有効にする必要があります。
気を付けましょう...
これについてはもうちょっと親切になると嬉しいので p-r を出してみました。カイゼンされるといいですね。
丸め損ねた ID
さて、local で問題なく動作していたということは Number.MAX_SAFE_INTEGER より小さい値が使われていたのでしょうか?
いいえ、手元で検証したユーザーの ID は101047568754016256 でした。
つまり、 Number.MAX_SAFE_INTEGER よりも大きいんですよね。
ではなぜ問題なく動作していたのでしょうか?
まずは、101047568754016256 がどのように丸められていたのかを確認します。
> Number("101047568754016256")
« 101047568754016260
> BigInt(101047568754016260)
« 101047568754016256n
なるほど、101047568754016260 から 101047568754016256 に戻っていますね。
しかし、一度精度が落ちた値が BigInt になることで 101047568754016256 に戻るのは不思議です。
なぜ BigInt(101047568754016260) が 101047568754016260n ではなく101047568754016256n になるんでしょうか?
> Number(101047568754016256).toString(2)
« '101100110111111100011101001111001000000000000000000000000'
> Number(101047568754016260).toString(2)
« '101100110111111100011101001111001000000000000000000000000'
> BigInt("0b101100110111111100011101001111001000000000000000000000000")
« 101047568754016256n
見かけ上は 101047568754016260 表記になっています(なんで?)が、どちらも内部では 101047568754016256 に丸められていたようです。
つまり local ではちょうど丸められた精度ピッタリの値を持つユーザー ID を使っていたので、偶然動いていたというわけです。
いかがでしたか?
人知れず local で回避できていたのは運が良かったのか、悪かったのか分かりませんが、希少な?落とし穴を体験できて面白かったです。