mcpField
U
uuid-primary-keys

uuid-primary-keys

github.com/thinkoodle開発7176 回インストール8 週間前に更新
リポジトリ

uuid-primary-keys について

PostgreSQL または SQLite で UUID 主キーを実装する Rails 向けスキルです。UUIDv7、マイグレーション、ジェネレーター、フィクスチャ、よくある誤りを案内します。

主な機能

  1. 01PostgreSQL のネイティブ UUID 設定
  2. 02SQLite のバイナリ UUID と Base36
  3. 03UUIDv7 の生成指針
  4. 04ジェネレーターとマイグレーション設定
  5. 05フィクスチャと関連付けの説明

活用シーン

  1. 01Rails アプリで UUID 主キーを導入
  2. 02UUID 外部キーを一貫して追加
  3. 03PostgreSQL または SQLite の UUID 保存方式を選択

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/thinkoodle/rails-skills --skill uuid-primary-keys

インストール後、Claude Code で/uuid-primary-keysを入力して読み込まれたか確認します。

同梱ファイルreference.md

詳細

Rails の UUID 主キー

Rails で UUID 主キーを正しく実装する方法です。PostgreSQL のネイティブ型、または SQLite のバイナリストレージを使用し、UUIDv7 とオプションの base36 エンコードに対応します。

方針:UUIDv4 より UUIDv7

常に UUIDv7 を推奨します。 時系列順に並び、ソート可能で、UUIDv4 よりインデックスのパフォーマンスに優れています。Ruby 3.3 以降には SecureRandom.uuid_v7 が標準搭載されているため、gem は不要です。

機能オートインクリメントUUIDv4UUIDv7
時刻順にソート可能はいいいえはい
インデックスのパフォーマンス最良低い(ランダム)良好(連続的)
衝突のリスクなし非常に低い非常に低い
外部から推測可能はいいいえいいえ
マージに安全いいえはいはい

ID が URL、API、または複数データベース間の同期に現れる場合は UUID を使用してください。外部に公開しない内部専用テーブルには整数を使用してください。

2 つの方法

PostgreSQL:ネイティブ UUID カラム型
  • 128 ビットのネイティブ型(16 バイト)として保存される uuid カラム型
  • PostgreSQL 13 以降では、拡張機能なしで gen_random_uuid() を利用可能
  • 標準のハイフン付き 36 文字形式:019533c0-cf4c-7c67-bfad-b7bc2832e3c5
  • 基本的なセットアップにカスタムコードは不要
SQLite:バイナリストレージ+Base36 エンコード
  • 生のバイナリを保存する blob(16) カラム(16 バイトで効率的)
  • URL に使いやすい 25 文字の文字列にする Base36 エンコード:0k5p9qi1v8j3h6m2n4b7
  • カスタム型の登録とアダプター拡張が必要
  • Basecamp の Fizzy 向けに当初開発されました

PostgreSQL のセットアップ

手順 1:ジェネレーターのデフォルトを設定する

これはエージェントが最もよく間違える点です。設定を忘れないでください。 これを設定しないと、rails generate model を実行するたびに整数 ID が作成され、すべてのマイグレーションを手動で修正することになります。

# config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.orm :active_record, primary_key_type: :uuid
end

これにより、生成されるすべてのマイグレーションで id: :uuid が使用され、生成されるすべての外部キーのカラムで type: :uuid が使用されます。

手順 2:pgcrypto を有効にする(PostgreSQL 13 未満の場合のみ)

PostgreSQL 13 以降では gen_random_uuid() が組み込まれています。それ以前のバージョンでは、次のようにします。

class EnablePgcrypto < ActiveRecord::Migration[8.0]
  def change
    enable_extension "pgcrypto"
  end
end
手順 3:UUID のデフォルト生成を設定する

UUIDv7(推奨)の場合は、ApplicationRecord で設定します。

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  # Generate UUIDv7 for all UUID primary keys
  before_create :set_uuid_v7_primary_key

  private

  def set_uuid_v7_primary_key
    pk = self.class.primary_key
    return unless pk

    column = self.class.columns_hash[pk]
    return unless column&.type == :uuid

    self[pk] ||= SecureRandom.uuid_v7
  end
end

default: "gen_random_uuid()" を使わない理由は? これはデータベース側で UUIDv4 を生成します。UUIDv7 は Ruby で生成する必要があります(PostgreSQL にネイティブの v7 サポートが追加されるまでは)。ApplicationRecord でデフォルトを設定すると、あらゆる場所で UUIDv7 が使われます。

UUIDv4 で問題なければ、ApplicationRecord のコールバックを省略して、データベースのデフォルトを設定できます。

create_table :posts, id: :uuid, default: "gen_random_uuid()" do |t|
  # ...
end
手順 4:マイグレーションを作成する
class CreatePosts < ActiveRecord::Migration[8.0]
  def change
    create_table :posts, id: :uuid do |t|
      t.string :title, null: false
      t.text :body
      t.timestamps
    end
  end
end

外部キーは UUID を参照します。

class CreateComments < ActiveRecord::Migration[8.0]
  def change
    create_table :comments, id: :uuid do |t|
      t.references :post, type: :uuid, null: false, foreign_key: true, index: true
      t.text :body, null: false
      t.timestamps
    end
  end
end

重要: t.references には必ず type: :uuid を指定してください。手順 1 を実施していればジェネレーターのデフォルトで設定されますが、必ず確認してください。

SQLite のセットアップ(バイナリストレージ+Base36)

この方法では UUID を 16 バイトのバイナリブロブとして保存し、Ruby では 25 文字の base36 文字列として表現します。効率的で、URL に使いやすい簡潔な ID を生成できます。

手順 1:UUID 型を作成する
# lib/rails_ext/active_record_uuid_type.rb
# frozen_string_literal: true

module ActiveRecord
  module Type
    class Uuid < Binary
      BASE36_LENGTH = 25 # 36^25 > 2^128

      class << self
        def generate
          uuid = SecureRandom.uuid_v7
          hex = uuid.delete("-")
          hex_to_base36(hex)
        end

        def hex_to_base36(hex)
          hex.to_i(16).to_s(36).rjust(BASE36_LENGTH, "0")
        end

        def base36_to_hex(base36)
          base36.to_s.to_i(36).to_s(16).rjust(32, "0")
        end
      end

      def serialize(value)
        return unless value
        binary = Uuid.base36_to_hex(value).scan(/../).map(&:hex).pack("C*")
        super(binary)
      end

      def deserialize(value)
        return unless value
        hex = value.to_s.unpack1("H*")
        Uuid.hex_to_base36(hex)
      end

      def cast(value)
        value
      end
    end
  end
end

ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :sqlite3)
手順 2:イニシャライザーを作成する
# config/initializers/uuid_primary_keys.rb
# frozen_string_literal: true

require_relative "../../lib/rails_ext/active_record_uuid_type"

# Auto-generate UUID for models with UUID primary keys
module UuidPrimaryKeyDefault
  def load_schema!
    define_uuid_primary_key_pending_default
    super
  end

  private

  def define_uuid_primary_key_pending_default
    if uuid_primary_key?
      pending_attribute_modifications << PendingUuidDefault.new(primary_key)
    end
  rescue ActiveRecord::StatementInvalid
    # Table doesn't exist yet
  end

  def uuid_primary_key?
    table_name && primary_key &&
      schema_cache.columns_hash(table_name)[primary_key]&.type == :uuid
  end

  PendingUuidDefault = Struct.new(:name) do
    def apply_to(attribute_set)
      attribute_set[name] = attribute_set[name].with_user_default(-> { ActiveRecord::Type::Uuid.generate })
    end
  end
end

# SQLite adapter extensions for UUID support
module SqliteUuidAdapter
  extend ActiveSupport::Concern

  def lookup_cast_type(sql_type)
    if sql_type == "blob(16)"
      ActiveRecord::Type.lookup(:uuid, adapter: :sqlite3)
    else
      super
    end
  end

  def fetch_type_metadata(sql_type)
    if sql_type == "blob(16)"
      ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
        sql_type: sql_type, type: :uuid, limit: 16
      )
    else
      super
    end
  end

  class_methods do
    def native_database_types
      @native_database_types_with_uuid ||= super.merge(uuid: { name: "blob", limit: 16 })
    end
  end
end

# Schema dumper: output :uuid instead of blob(16)
module SchemaDumperUuidType
  def schema_type(column)
    column.sql_type == "blob(16)" ? :uuid : super
  end
end

# Table definition: support t.uuid columns
module TableDefinitionUuidSupport
  def uuid(name, **options)
    column(name, :uuid, **options)
  end
end

# Apply extensions
ActiveSupport.on_load(:active_record) do
  ActiveRecord::Base.singleton_class.prepend(UuidPrimaryKeyDefault)
  ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionUuidSupport)
end

ActiveSupport.on_load(:active_record_sqlite3adapter) do
  ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter)
  ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType)
end

# Apply immediately if already loaded
if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
  unless ActiveRecord::ConnectionAdapters::SQLite3Adapter.ancestors.include?(SqliteUuidAdapter)
    ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter)
  end
end

if defined?(ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper)
  unless ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.ancestors.include?(SchemaDumperUuidType)
    ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType)
  end
end
手順 3:ApplicationRecord にフォールバックを追加する
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  before_create :ensure_uuid_primary_key

  private

  def ensure_uuid_primary_key
    pk = self.class.primary_key
    return unless pk

    column = self.class.columns_hash[pk]
    return unless column&.type == :uuid

    self[pk] ||= ActiveRecord::Type::Uuid.generate
  end
end
手順 4:ジェネレーターを設定する
# config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.orm :active_record, primary_key_type: :uuid
end
手順 5:マイグレーション(PostgreSQL と同じ)
class CreatePosts < ActiveRecord::Migration[8.0]
  def change
    create_table :posts, id: :uuid do |t|
      t.string :title, null: false
      t.text :body
      t.timestamps
    end
  end
end

外部キーには t.uuid を直接使用します。

class CreateComments < ActiveRecord::Migration[8.0]
  def change
    create_table :comments, id: :uuid do |t|
      t.uuid :post_id, null: false, index: true
      t.text :body, null: false
      t.timestamps
    end
  end
end

UUID を使ったフィクスチャ

PostgreSQL(標準 UUID 形式)
# test/fixtures/posts.yml
published_post:
  id: <%= SecureRandom.uuid_v7 %>
  title: "Published Post"
  created_at: <%= 1.week.ago %>

draft_post:
  id: <%= SecureRandom.uuid_v7 %>
  title: "Draft Post"

フィクスチャ ID を決定的にする場合(実行をまたいで安定させる場合):

published_post:
  id: <%= Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "published_post") %>
  title: "Published Post"
SQLite Base36
# test/fixtures/posts.yml
published_post:
  id: <%= ActiveRecord::Type::Uuid.generate %>
  title: "Published Post"
フィクスチャ内の外部キー参照

必ず生の ID ではなく、フィクスチャ名で参照してください。

# test/fixtures/comments.yml
first_comment:
  post: published_post  # ← Rails resolves this to the UUID automatically
  body: "Great post!"

アソシエーション

アソシエーションは整数の主キーと同じように機能します。特別な設定は不要です。

class Post < ApplicationRecord
  has_many :comments, dependent: :destroy
end

class Comment < ApplicationRecord
  belongs_to :post
end

変更されるのはマイグレーションだけです。 モデル、アソシエーション、クエリはすべて同じです。

よくある間違い

1. ジェネレーターのデフォルト設定を忘れる

ジェネレーターに primary_key_type: :uuid を設定しないと、scaffold のたびに整数 ID が作成されます。すべてのマイグレーションを手動で修正することになります。

2. UUID 型ではなく文字列カラムを使う
# WRONG — stores UUID as variable-length string (36 bytes + overhead)
t.string :post_id

# RIGHT — PostgreSQL native uuid (16 bytes)
t.references :post, type: :uuid

# RIGHT — SQLite binary (16 bytes)
t.uuid :post_id
3. references に type: :uuid を指定し忘れる
# WRONG — creates integer foreign key column
t.references :post, foreign_key: true

# RIGHT — creates UUID foreign key column
t.references :post, type: :uuid, foreign_key: true
4. v7 が利用可能な場合に UUIDv4 を使う

UUIDv4 は完全にランダムなため、インデックスの局所性が低く、大規模になると挿入が遅くなります。UUIDv7 は時系列順のため、順次挿入となり、パフォーマンスが向上します。Ruby 3.3 以降が必要です。

5. 整数と UUID の主キーを混在させる

UUID から始める場合は、すべての箇所で UUID を使用してください。ID の型が混在すると、結合時の混乱や移行の煩雑さにつながります。例外は、独自の ID が不要な結合テーブル(id: false)です。

6. 既存のテーブルへの対応を忘れる

既存のアプリを整数の主キーから UUID の主キーに変換する場合は、データ移行の戦略が必要です。既存の主キーをその場で変更しようとしないでください。手間がかかります。代わりに、新しい UUID カラムを作成してデータを移行し、その後に切り替えるのがよい方法です。

URL に適した ID

Base36(SQLite の方法)

上記の SQLite セットアップにすでに組み込まれています。ID は次のようになります:0k5p9qi1v8j3h6m2n4b7

PostgreSQL:標準の UUID を使用する

最新のフレームワークや API では、標準の UUID を問題なく扱えます。URL を短くしたい場合は、主キーをエンコードするのではなく、別の slug または short_id カラムを追加してください。

ルートパラメーター

UUID は変更せずにルートで使用できます:

# config/routes.rb
resources :posts  # Works identically

# Controller
Post.find(params[:id])  # Finds by UUID string

現在のセットアップを確認する

実装する前に、必ず既存のプロジェクトを確認してください:

# Check if generator defaults are set
grep -r "primary_key_type" config/

# Check existing migrations for UUID usage
grep -r "id: :uuid" db/migrate/

# Check for pgcrypto extension
grep -r "pgcrypto" db/migrate/ db/schema.rb

# Check database adapter
grep "adapter:" config/database.yml

# Check Ruby version (need 3.3+ for uuid_v7)
ruby -v

参考資料

このスキルディレクトリにある reference.md を参照してください。内容は次のとおりです:

  • SQLite イニシャライザーの完全なコード
  • Base36 のエンコード/デコードの詳細
  • 移行時の変換戦略
  • テストのパターンとエッジケース
  • パフォーマンスのベンチマーク
  • マルチデータベースに関する考慮事項

コメント