はじめに
個人開発でWebアプリを作っていた時にハマったエラーの振り返りを行い、今後の開発に活かしていく目的で記事をまとめています。
私が作成しているWebアプリ(IT技術学習アプリ)でユーザの進捗情報をSupabaseに保存しようとしている際にエラーが発生した時の話です。
エラー内容:
ERROR: invalid input syntax for type uuid: "local-user-1234567890"
知識不足により、なぜこのようなエラーが発生しているかがすぐに判別できなかったのですが、後の調査により、これはSupabase(PostgreSQL)のUUID型の厳格な形式チェックにプログラムで作成したユーザIDの形式が合わなかったことが原因でした。
何が起きたのか?
エラーの発生状況
ユーザー認証システムで、ゲストユーザーに対してこんなIDを生成していました:
// ❌ これがエラーの原因
const guestUserId = 'local-user-' + Date.now();
// 結果: "local-user-1234567890"
そして、このIDをSupabaseのテーブルに保存しようとした時にエラーが発生:
// ProgressService.saveQuestionProgress でエラー
await supabase
.from('user_progress')
.insert({
user_id: guestUserId, // ← "local-user-1234567890"
theme_id: 'math',
sub_theme_id: 'basic'
});
問題となるテーブル定義
実際のSupabaseで定義されていたテーブル定義を見てみます:
-- user_progressテーブルの定義
CREATE TABLE user_progress (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL, -- ← ここがUUID型でエラーの原因!
theme_id TEXT NOT NULL,
sub_theme_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Supabaseテーブル定義の詳細解説
このテーブル定義の各要素を詳しく見ていきましょう。データベース初心者(自身含む!)の方にも分かりやすく説明していきます。
1. id UUID DEFAULT uuid_generate_v4() PRIMARY KEY
PRIMARY KEY(主キー)とは?
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY
↑ ↑
カラム名 主キー指定
PRIMARY KEYの役割:
- 一意性保証: テーブル内で重複しない値を持つ
- 識別子: 各行を一意に特定するためのカラム
- インデックス: 自動的に高速検索用のインデックスが作成される
- NULL禁止: PRIMARY KEYは自動的にNOT NULL制約が付く
実際の動作例:
// ✅ 正常なケース - 各行が一意のidを持つ
user_progress テーブル
┌──────────────────────────────────────┬─────────────┬───────────┐
│ id (PRIMARY KEY) │ user_id │ theme_id │
├──────────────────────────────────────┼─────────────┼───────────┤
│ 550e8400-e29b-41d4-a716-446655440000 │ user_001 │ math │
│ 6ba7b810-9dad-11d1-80b4-00c04fd430c8 │ user_002 │ science │
│ f47ac10b-58cc-4372-a567-0e02b2c3d479 │ user_001 │ english │
└──────────────────────────────────────┴─────────────┴───────────┘
// ❌ エラーになるケース - 同じidで挿入しようとする
INSERT INTO user_progress (id, user_id, theme_id)
VALUES ('550e8400-e29b-41d4-a716-446655440000', 'user_003', 'history');
-- ERROR: duplicate key value violates unique constraint "user_progress_pkey"
DEFAULT uuid_generate_v4() とは?
DEFAULT uuid_generate_v4()
↑ ↑
デフォルト値 関数
動作説明:
- INSERT時に
idを指定しなかった場合、自動的にUUIDが生成される -
uuid_generate_v4()はランダムなUUID v4を生成するPostgreSQL関数 - Supabaseでは標準で利用可能
実際の使用例:
// ✅ idを指定しない場合 - 自動生成
await supabase.from('user_progress').insert({
user_id: 'user_123',
theme_id: 'math',
sub_theme_id: 'basic'
});
// → id は自動的に "f47ac10b-58cc-4372-a567-0e02b2c3d479" のような値になる
// ✅ idを明示的に指定する場合
await supabase.from('user_progress').insert({
id: '123e4567-e89b-12d3-a456-426614174000',
user_id: 'user_123',
theme_id: 'math',
sub_theme_id: 'basic'
});
2. user_id UUID NOT NULL
NOT NULL制約とは?
user_id UUID NOT NULL
↑ ↑
データ型 NULL禁止
NOT NULLの意味:
- このカラムには必ず値を入れる必要がある
- 空の値(NULL)は許可されない
- データの整合性を保つための制約
実際の動作例:
// ❌ user_idを指定しないとエラー
await supabase.from('user_progress').insert({
theme_id: 'math',
sub_theme_id: 'basic'
});
// ERROR: null value in column "user_id" violates not-null constraint
// ❌ user_idにnullを指定してもエラー
await supabase.from('user_progress').insert({
user_id: null,
theme_id: 'math',
sub_theme_id: 'basic'
});
// ERROR: null value in column "user_id" violates not-null constraint
// ✅ 正しい指定
await supabase.from('user_progress').insert({
user_id: '550e8400-e29b-41d4-a716-446655440000',
theme_id: 'math',
sub_theme_id: 'basic'
});
UUID型の厳格さ
UUIDとは、「Universally Unique Identifier」の略で、世界中で重複しないユニークなIDを生成するための仕組みです。
user_id UUID
↑
厳格な型チェック
UUIDは以下の形式のみ許可:
- 正確な文字数: 36文字(ハイフン含む)
- ハイフンの位置: 8-4-4-4-12の構成
- 使用文字: 0-9, a-f(16進数)
// ✅ 正しいUUID形式
"550e8400-e29b-41d4-a716-446655440000"
"6BA7B810-9DAD-11D1-80B4-00C04FD430C8" // 大文字も可
// ❌ 間違った形式 - すべてエラーになる
"local-user-123" // 形式が違う
"550e8400e29b41d4a716" // ハイフンなし
"hello-world-test-data" // 16進数以外の文字
"" // 空文字
"123" // 短すぎる
3. theme_id TEXT NOT NULL と sub_theme_id TEXT NOT NULL
TEXT型とは?
theme_id TEXT NOT NULL
↑ ↑
可変長文字列 NULL禁止
TEXT型の特徴:
- 可変長: 文字数に制限なし(実用上は1GB程度まで)
- 柔軟性: 任意の文字列を格納可能
- UTF-8対応: 日本語、絵文字なども格納可能
実際の使用例:
// ✅ TEXT型なので様々な形式に対応
await supabase.from('user_progress').insert({
user_id: '550e8400-e29b-41d4-a716-446655440000',
theme_id: 'javascript-basics', // ハイフン付き
sub_theme_id: 'variables_and_functions' // アンダースコア付き
});
await supabase.from('user_progress').insert({
user_id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
theme_id: '数学基礎', // 日本語
sub_theme_id: '四則演算🧮' // 絵文字も可能
});
// ❌ NOT NULL制約違反
await supabase.from('user_progress').insert({
user_id: '550e8400-e29b-41d4-a716-446655440000',
theme_id: null, // エラー: NOT NULL制約違反
sub_theme_id: 'basic'
});
4. created_at TIMESTAMP DEFAULT NOW()
TIMESTAMP型とは?
created_at TIMESTAMP DEFAULT NOW()
↑ ↑
日時型 現在時刻をデフォルト
TIMESTAMP型の特徴:
- 日時情報: 年月日 + 時分秒を格納
- 精度: マイクロ秒まで記録可能
- タイムゾーン: UTCで保存(Supabaseの場合)
NOW() 関数の動作
DEFAULT NOW()
↑ ↑
デフォルト値 現在時刻を取得する関数
実際の動作例:
// ✅ created_atを指定しない場合 - 自動的に現在時刻が設定
await supabase.from('user_progress').insert({
user_id: '550e8400-e29b-41d4-a716-446655440000',
theme_id: 'math',
sub_theme_id: 'basic'
});
// → created_at は自動的に "2024-01-15 14:30:25.123456" のような値になる
// ✅ created_atを明示的に指定することも可能
await supabase.from('user_progress').insert({
user_id: '550e8400-e29b-41d4-a716-446655440000',
theme_id: 'math',
sub_theme_id: 'basic',
created_at: '2024-01-01 00:00:00'
});
データの見え方:
// Supabaseから取得したデータ
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"theme_id": "javascript",
"sub_theme_id": "variables",
"created_at": "2024-01-15T14:30:25.123456+00:00"
}
テーブル定義の全体像
このテーブル設計の意図を整理すると:
CREATE TABLE user_progress (
-- 🔑 各進捗レコードの一意識別子(自動生成)
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
-- 👤 どのユーザーの進捗か(必須、UUID形式)
user_id UUID NOT NULL,
-- 📚 どのテーマの進捗か(必須、自由形式)
theme_id TEXT NOT NULL,
-- 📖 どのサブテーマの進捗か(必須、自由形式)
sub_theme_id TEXT NOT NULL,
-- ⏰ いつ記録されたか(自動記録)
created_at TIMESTAMP DEFAULT NOW()
);
設計思想:
1. 一意性: idで各レコードを確実に識別
2. 関連性: user_idでユーザーとの関連付け
3. 分類: theme_idとsub_theme_idで学習内容を分類
4. 履歴: created_atで学習時刻を記録
このテーブル構造では、user_idカラムがUUID型で定義されているため、ここに"local-user-123"のような文字列を入れようとすることでエラーが発生していました。
UUID型がなぜこんなに厳格なのか?
UUID型が厳格な形式を要求する理由は、以下の通りです:
1. データ整合性の保護
// ❌ 自由形式だと混沌とする
const users = [
{ id: "user1" },
{ id: "admin" },
{ id: "" }, // 空文字
{ id: "🎉🎉🎉" }, // 絵文字
{ id: null } // null
];
// ✅ UUID形式なら統一される
const users = [
{ id: "550e8400-e29b-41d4-a716-446655440000" },
{ id: "6ba7b810-9dad-11d1-80b4-00c04fd430c8" }
];
2. セキュリティ対策
// ❌ 推測されやすい
"user1", "user2", "admin"
// ❌ SQLインジェクション攻撃の例
const attackId = "'; DROP TABLE users; --";
// もしこのIDが直接SQL文に埋め込まれると...
// INSERT INTO user_progress (user_id) VALUES (''); DROP TABLE users; --')
// → usersテーブルが削除される大災害!
// ✅ UUID型なら攻撃を防げる
// "'; DROP TABLE users; --" → ERROR: invalid input syntax for type uuid
// 型チェックで弾かれるため、悪意のあるSQLが実行されない
// ✅ UUID = 推測不可能で安全
"f47ac10b-58cc-4372-a567-0e02b2c3d479"
3. システム間の衝突防止
// 複数システムで同じIDが生成される可能性
システムA: "user1", "user2"
システムB: "user1", "user2" // 衝突!
// UUIDなら衝突確率は天文学的に低い
システムA: "550e8400-e29b-41d4-a716-446655440000"
システムB: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
解決策
1. JavaScriptでUUID生成
モダンブラウザ(一定以上新しいブラウザ)の場合:
// ✅ crypto.randomUUID() を使用
const generateUUID = () => {
return crypto.randomUUID();
};
const guestUserId = generateUUID();
// 結果: "550e8400-e29b-41d4-a716-446655440000"
ライブラリを使用する場合:
// npm install uuid
import { v4 as uuidv4 } from 'uuid';
const guestUserId = uuidv4();
// 結果: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
2. DeviceIdManager関数 の修正
元のコードを修正:
// ❌ 修正前
export const DeviceIdManager = {
getDeviceId: async () => {
let deviceId = localStorage.getItem('deviceId');
if (!deviceId) {
deviceId = 'local-user-' + Date.now(); // ← これがダメ
localStorage.setItem('deviceId', deviceId);
}
return deviceId;
}
};
// ✅ 修正後
export const DeviceIdManager = {
getDeviceId: async () => {
let deviceId = localStorage.getItem('deviceId');
if (!deviceId) {
deviceId = crypto.randomUUID(); // ← UUID形式で生成
localStorage.setItem('deviceId', deviceId);
}
return deviceId;
}
};
3. フォールバックユーザー(アプリでエラーが発生した場合の緊急用ユーザー)として作成されるIDをUUID形式に修正
// ❌ 修正前
const createFallbackUser = () => {
return {
id: 'error-fallback-user', // ← これもダメ
profile: { nickname: 'ゲストさん', isFirstTime: false }
};
};
// ✅ 修正後
const createFallbackUser = () => {
return {
id: crypto.randomUUID(), // ← UUID形式
profile: { nickname: 'ゲストさん', isFirstTime: false }
};
};
学んだこと・注意点
1. データベース設計を理解する
- テーブル定義を確認する習慣をつける
- カラムの型制約を把握する
- エラーメッセージから型の問題を読み取る
2. UUIDの特性を理解する
- 厳格な形式(8-4-4-4-12)
- 16進数のみ使用可能
- ハイフンは必須
3. 適切なID生成方法を使う
// ✅ 推奨パターン
const generateId = () => {
// モダンブラウザ
if (crypto.randomUUID) {
return crypto.randomUUID();
}
// フォールバック(ライブラリ使用)
return uuidv4();
};
4. エラーハンドリングの実装
問題:データベースでエラーが起きると何が困る?
// ❌ UUID形式チェックなしの場合
const saveUserProgress = async (userId, data) => {
try {
await supabase.from('user_progress').insert({
user_id: userId, // "local-user-123" が渡される
...data
});
} catch (error) {
// エラーが起きてから気づく
console.error('保存失敗:', error);
// でも、もう手遅れ...
}
};
// 使用例
await saveUserProgress('local-user-123', { theme_id: 'math' });
// ❌ ERROR: invalid input syntax for type uuid: "local-user-123"
// → ユーザーは「なんかエラーが起きた」としか分からない
// → デバッグも大変
解決:事前チェックで問題を防ぐ
// ✅ UUID形式チェック付きの場合
const saveUserProgress = async (userId, data) => {
try {
// 📋 事前チェック:UUID形式か?
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
throw new Error('Invalid UUID format');
}
// ✅ チェック通過後にDB操作
await supabase.from('user_progress').insert({
user_id: userId,
...data
});
} catch (error) {
console.error('Error saving progress:', error);
// 適切なエラーハンドリング
}
};
まとめ
今回のエラーから、以下の重要な学びを得ました:
- データベースの型制約は厳格に守る必要がある
- UUIDには明確な形式があり、それには理由がある
- 適切なライブラリやAPIを使用してUUIDを生成する
- エラーメッセージから問題の本質を読み取る力が重要
個人開発では「とりあえず動けばOK」と高速で進めがちですが、こうした基本的な部分でつまずくことがあります。特にデータベース周りは型制約が厳しいので、設計段階での理解が重要だと改めて感じました。
IT技術学習アプリ
私が作成に取り組んでいる「IT技術学習アプリ」です。React等の技術について学習できるアプリになっています。
現在は開発中のベータ版ですが、周辺技術も学習しながら継続して改善中です。