気分記録アプリを作っていて、「日付なんて文字列で入れておけばいいでしょ」と思っていたら、画面を見ても絶対に気づけないバグの入り口だと知りました。その記録です。
SQLite とは何か(MySQL を知っている人向け)
私は phpMyAdmin で MySQL を触ったことがある、くらいの経験でした。
今回 Expo でアプリを作るにあたって SQLite というものを使ったのですが、同じ「SQL を書くデータベース」なのに、前提がかなり違って混乱しました。
一番の違いはこれです。
MySQL は「サービス」。SQLite は「ファイル」。
MySQL の場合
MySQL はサーバーとして常駐しているプログラムです。だから接続するのに、
- ホスト名(localhost とか)
- ポート番号(3306)
- ユーザー名とパスワード
- データベース名
が必要でした。phpMyAdmin は、その MySQL サーバーにブラウザから話しかけるための別のソフトです。
SQLite の場合
SQLite にはサーバーがありません。mood.db という1個のファイルがデータベースそのものです。
接続情報: なし
ユーザー名: なし
パスワード: なし
ポート: なし
やること: ファイルを開く
私が書いたコードも、実際こうなっています。
const db = await openDatabaseAsync('mood.db');
これだけです。「接続」ではなく「ファイルを開いている」。
この1行の意味が分かった瞬間に、だいぶスッキリしました。
比較表
| MySQL | SQLite | |
|---|---|---|
| 正体 | 常駐するサーバープログラム | 1個のファイル |
| 接続情報 | ホスト・ポート・ユーザー・パスワード | 不要 |
| 誰が使う | 複数のアプリ・複数の人が同時に | そのアプリだけ |
| 置き場所 | サーバーの中 | アプリの中(今回は iPhone の中) |
| 管理画面 | phpMyAdmin | 無い(後述) |
| 向いてる用途 | Web サービス | スマホアプリ・組み込み |
なぜスマホアプリでは SQLite なのか
今回のアプリは「気分の記録を端末の外に出さない」という方針でした。
通院記録という、かなり繊細な情報も扱うからです。
MySQL だと、どこかにサーバーを置いて、そこにデータを送ることになります。サーバーがある時点で、情報漏洩のリスクと、サーバー代と、ログイン機能が発生します。
SQLite なら、データは iPhone の中のファイル1個。開発者である私も中身を見られません。サーバーが無いことが、そのままセキュリティになるという設計です。
⚠️ 注意:phpMyAdmin がありません
これは正直きつかった点です。
MySQL なら phpMyAdmin を開けば、テーブルの中身が表形式でパッと見られました。SQLite は iPhone の中にあるファイルなので、ブラウザから覗くことができません。
中身を確認する方法は別途あるようですが、少なくとも「とりあえず phpMyAdmin で見る」という手癖は使えません。デバッグの感覚がかなり変わります。
SQLite には「日付型」が無い
ここから本題です。
MySQL には DATE 型や DATETIME 型がありました。phpMyAdmin でテーブルを作るとき、プルダウンから選んでいたあれです。
SQLite には日付型がありません。
SQLite が持っている型は、たったこれだけです。
| 型 | 中身 |
|---|---|
TEXT | 文字列 |
INTEGER | 整数 |
REAL | 小数 |
BLOB | バイナリ |
NULL | 空 |
VARCHAR(255) もありません。DATE もありません。
なので日付は、自分で決めた形式の文字列(TEXT)として入れることになります。私が書いたテーブル定義はこうなりました。
CREATE TABLE moods (
date TEXT PRIMARY KEY NOT NULL, -- '2026-08-02'
mood INTEGER NOT NULL CHECK (mood BETWEEN 1 AND 10),
memo TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
なぜ 'YYYY-MM-DD' という形式なのか
これは慣習ではなく、明確な理由があると知って驚きました。
この形式は、文字列として並べ替えると、時系列順と完全に一致します。
'2026-01-05'
'2026-01-31'
'2026-02-01' ← 文字列比較でも、ちゃんとこの順になる
だから日付型が無くても、
SELECT * FROM moods WHERE date BETWEEN '2026-08-01' AND '2026-08-31' ORDER BY date
がそのまま正しく動きます。もしこれが '2026/8/2' みたいな形式だったら、'2026/10/1' より '2026/8/2' のほうが後ろに来てしまって(1 < 8 なので)、並び順が壊れます。
日付型が無いぶん、形式選びが自分の責任になるわけです。
本題の罠 — 深夜に記録すると前日になる
さて、ここからが今回一番学んだところです。
「Date型のデータを 'YYYY-MM-DD' の文字列にすればいいんでしょ」と思って調べると、こういうコードが山ほど出てきます。
const key = date.toISOString().slice(0, 10); // '2026-08-02'
短くて綺麗です。そして、日本で使うと壊れます。
何が起きるか
スマホやパソコンの中では、時刻は 世界標準時(UTC、だいたいイギリスの時間) を基準に管理されています。日本はそこから 9時間進んでいます。
つまり、こうなります。
| 日本の時刻 | 世界標準時(UTC) |
|---|---|
| 8月2日 00:30(深夜) | 8月1日 15:30 ← 前日! |
| 8月2日 08:59 | 8月1日 23:59 ← まだ前日 |
| 8月2日 09:00 | 8月2日 00:00 ← ここでやっと追いつく |
| 8月2日 23:59 | 8月2日 14:59 |
toISOString() は 世界標準時に変換してから文字列にします。
なので 日本時間の 0:00〜8:59 に記録すると、前の日として保存されます。
実際に確かめてみた
同じコードを、タイムゾーンだけ変えて動かしてみました。日本時間の 2026年1月1日 0時30分 のつもりのデータです。
Asia/Tokyo 正しい: 2026-01-01 | toISOString: 2025-12-31 ← 年まで違う
UTC 正しい: 2026-01-01 | toISOString: 2026-01-01
元日の深夜に記録したら、去年の大晦日として保存されていました。
何が一番怖いか
バグそのものより、発見できないことが怖いと思いました。
- エラーは出ません
- 赤い画面も出ません
- カレンダーにはちゃんと色が付きます
- 数字も正しく表示されます
ただ、1日ずれているだけ。
しかも今回のアプリは「診察のときに医師に見せる記録」にもなるものです。
「先週の火曜がしんどかった」という話が全部1日ずれている状態で、それに誰も気づかない。
数か月後に気づいたときには、すでに保存されたデータは直しようがありません(どの記録が深夜入力だったのか、後からは判別できないので)。
対策:変換する場所を1箇所だけにする
やったことは単純です。日付を文字列にする処理を、アプリ全体で1個だけにしました。
// src/utils/date.ts ← アプリ内で唯一の変換口
export function toDateKey(date: Date): string {
const pad2 = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
getFullYear() / getMonth() / getDate() は、端末のタイムゾーンで解釈されるメソッドです。UTC への変換が一度も発生しないので、ズレようがありません。
ポイントは「正しい書き方を知ったこと」ではなく、「他の場所で書けないようにしたこと」だと思っています。
20箇所で日付を扱うなら、19箇所で正しく書けても意味がないので。
第4部:テストを書いたのに、守れていなかった話
この日付の処理には、自動テストを書きました。「深夜0時30分を渡したら、その日の日付が返ること」を確認するテストです。
it('深夜0時台でも、その日のローカル暦日を返す', () => {
const d = new Date(2026, 0, 1, 0, 30);
expect(toDateKey(d)).toBe('2026-01-01');
});
これで安心……と思ったのですが、このテストには穴がありました。
先ほどの比較表をもう一度見てください。
Asia/Tokyo 正しい: 2026-01-01 | 誤った実装: 2025-12-31 → テストは落ちる
UTC 正しい: 2026-01-01 | 誤った実装: 2026-01-01 → テストは通る
テストを動かすパソコンが世界標準時に設定されていたら、間違ったコードでもテストが「合格」になります。
私の Mac はたまたま日本時間だったので機能していましたが、これは運です。もし将来クラウド上(多くはUTC設定)で自動テストを回すようにしたら、テストは全部緑なのにバグは通り抜けるという、一番たちの悪い状態になっていました。
なので、テストが必ず日本時間で走るように固定しました。
"scripts": {
"test": "TZ=Asia/Tokyo jest"
}
「テストがあること」と「テストが守れていること」は別問題だというのが、今回一番の学びでした。書いたテストが本当にバグを捕まえられるか、一度わざと壊して確かめる価値があります。
まとめ
SQLite について
- MySQL は「サーバー」、SQLite は「ファイル1個」
- 接続情報もパスワードも不要。
openDatabaseAsync('mood.db')だけ - サーバーが無いこと自体が、そのままセキュリティになる
- ただし phpMyAdmin に相当する管理画面は無い
日付について
- SQLite に日付型は無い。
TEXTで'YYYY-MM-DD'を入れる - この形式なら、文字列の並べ替え = 時系列の並べ替えになる
toISOString()は世界標準時に変換する。日本の深夜0〜9時が前日にずれる- 対策は「正しく書く」ではなく「変換場所を1つに絞る」
- テストは、動かす環境のタイムゾーン次第で無力化される
日付は「ただの文字列」だと思っていました。実際には、画面を見ても分からず、後から直せず、しかも一番大事なデータがずれるという、なかなか凶悪な地雷でした。
この記事のカテゴリー
開発ブログ の記事をもっと見る →