mcpField
U
uuid-primary-keys

uuid-primary-keys

github.com/thinkoodle开发7176 次安装8 周前更新
代码仓库

关于 uuid-primary-keys

面向 Rails 开发者的 UUID 主键实施指南,涵盖 PostgreSQL 和 SQLite。内容侧重 UUIDv7、迁移、生成器配置、Fixtures 与常见错误。

主要功能

  1. 01配置 PostgreSQL 原生 UUID
  2. 02使用 SQLite 二进制 UUID 与 Base36
  3. 03UUIDv7 生成建议
  4. 04生成器与迁移配置
  5. 05Fixtures 和关联说明

适用场景

  1. 01为 Rails 应用采用 UUID 主键
  2. 02一致地添加 UUID 外键
  3. 03选择 PostgreSQL 或 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 编码。

决策:选择 UUIDv7,而非 UUIDv4

始终推荐 UUIDv7。 它按时间排序、可排序,且索引性能优于 UUIDv4。Ruby 3.3+ 自带 SecureRandom.uuid_v7,无需安装 gem。

特性自增UUIDv4UUIDv7
按时间排序是否是
索引性能最佳较差(随机)良好(顺序)
冲突风险不可能极低极低
可从外部猜测是否否
安全合并否是是

适用 UUID 的情况:ID 出现在 URL、API 或多数据库同步中。适用整数的情况:仅供内部使用且不会暴露的表。

两种方案

PostgreSQL:原生 UUID 列类型
  • uuid 列以原生 128 位类型存储(16 字节)
  • PostgreSQL 13+ 无需扩展即可使用 gen_random_uuid()
  • 标准的 36 字符连字符格式:019533c0-cf4c-7c67-bfad-b7bc2832e3c5
  • 基本设置无需自定义代码
SQLite:二进制存储 + Base36 编码
  • 使用 blob(16) 列存储原始二进制数据(16 字节,效率高)
  • 使用 Base36 编码生成 25 字符、适合 URL 的字符串:0k5p9qi1v8j3h6m2n4b7
  • 需要注册自定义类型并扩展适配器
  • 最初由 Basecamp 为 Fizzy 开发

PostgreSQL 设置

步骤 1:配置生成器默认值

这是 Agent 最常犯的错误——忘记这一步。 如果不设置,每次运行 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 字符串。它效率高,且生成的 ID 简洁、适合 URL 使用。

步骤 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 的 Fixtures

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"

设置确定性的 fixture 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"
Fixtures 中的外键引用

始终通过 fixture 名称引用,而不是使用原始 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 编码/解码详情
  • 迁移转换策略
  • 测试模式和边缘情况
  • 性能基准
  • 多数据库注意事项

评论

支持 Markdown