3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発振り返り】Supabaseで「UUID型エラー」にハマった話 - なぜlocal-user-123がダメだったのか?

3
Posted at

はじめに

個人開発で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 NULLsub_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_idsub_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等の技術について学習できるアプリになっています。
現在は開発中のベータ版ですが、周辺技術も学習しながら継続して改善中です。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?