【Rails】途中からidカラムをuuidに変更する

現在オンラインスクールにてプログラムの勉強をしているとぴ（@topi_log）と申します。
個人開発をしていく中で、idカラムをuuidカラムに変更したくなったので、その備忘録として残します。
初学者ゆえ、間違いなどありましたらそっと教えていただけますと幸いです。

# 開発環境
- WSL2（Ubuntu22.4）
- Docker
- PostgreSQL ver16
- Ruby on Rails ver7.1.3 APIモード
- Next.js ver14

※フロントとしてNext.jsを使っていますが今回はRails側の実装のみ扱います。

# 対象者
Ruby on Railsで開発途中にidをuuidに変更したい方
ここでの変更は、idをuuid型にしつつuuidというカラム名でプライマリーキーに設定することです。

### 変更理由
現在投稿型のアプリを作成していますが、公開範囲の中に「URLを知っている人」があります。
単純なidだとURLを知らなくても推測できてしまい秘匿性が薄れてしまうため、URLとしてidではなくuuidを使用することにしました。

# ER図
記事用に簡略化したERがこちらです。
<img src="https://i.gyazo.com/08d184ff0300ed56419747334ff919f3.png" width="400px" />
今回は「postのidをuuidに変換」する内容です。

# 流れ
1. uuidカラム追加
1. 外部キー設定されているところにpost_uuidカラムを追加
1. プライマリーキーをuuidにして外部キーを変更
1. indexを変更
1. idカラムを削除
1. idでやっている実装をuuidに変更

今回はマイグレーションを5つに分け最後にモデル等の実装を修正しました。

# 実装
### 1. uuidカラムを追加
uuidカラムをpostに追加します。また今回はPostgreSQLを使っているのでそれを前提に実装します。
```ruby
class AddUuid < ActiveRecord::Migration[7.1]
  def change
    # uuidカラムの追加
    add_column :posts, :uuid, :uuid, default: 'gen_random_uuid()', null: false

    # uuidの一意制約
    add_index :posts, :uuid, unique: true

    # 既存データのuuidカラムにデータ追加
    Post.reset_column_information # モデルの操作を行うのでキャッシュを削除
    Post.find_each { |post| post.update_column(:uuid, SecureRandom.uuid) }
  end
end
```
PostgreSQLのバージョンによってはuuidを使うために拡張機能の有効化が必要になります。
その際は`enable_extension 'pgcrypto'`を`change`の最初に追記します。

https://railsguides.jp/v7.1/active_record_postgresql.html#uuid

### 2. 外部キー設定されているところにpost_uuidカラムを追加
```ruby
class AddUuidForeignKey < ActiveRecord::Migration[7.1]
  def change
    # いいねテーブルにpost_uuidを追加
    add_column :favorites, :post_uuid, :uuid
    # タグの中間テーブルにpost_uuidを追加
    add_column :post_tags, :post_uuid, :uuid

    # 既存のデータにpost_uuidを設定
    # いいねテーブル
    Favorite.reset_column_information
    Favorite.find_each do |fav|
      fav.update_column(:post_uuid, Post.find(fav.post_id).uuid)
    end

    # タグの中間テーブル
    PostTag.reset_column_information
    PostTag.find_each { |pt| pt.update_column(:post_uuid, Post.find(pt.post_id).uuid) }

    # null制約を追加
    change_column_null :favorites, :post_uuid, false
    change_column_null :post_tags, :post_uuid, false
  end
end
```
`post_uuid`カラムを追加したとしても、既存のデータのpost_uuidは当然null値になってしまうので、`null false`の設定はあとから追加するようにしました。

### 3. プライマリーキーをuuidにして外部キーを変更
```ruby
class ChangePrimaryKey < ActiveRecord::Migration[7.1]
  def change
    # 外部キー制約を一旦解除
    remove_foreign_key :favorites, :posts
    remove_foreign_key :post_tags, :posts

    # プライマリキーをuuidに変更
    # 投稿テーブルのuuidをプライマリーキーに設定
    execute 'ALTER TABLE posts DROP CONSTRAINT posts_pkey;'
    execute 'ALTER TABLE posts ADD PRIMARY KEY (uuid);'

    # 外部キー制約を再設定
    add_foreign_key :favorites, :posts, column: :post_uuid, primary_key: :uuid
    add_foreign_key :post_tags, :posts, column: :post_uuid, primary_key: :uuid
  end
end
```
PostgreSQLにはテーブル定義を変更するSQLがあるので、それを利用してプライマリーキーをidからuuidに変更します。

https://www.postgresql.jp/document/16/html/sql-altertable.html

> ALTER TABLE distributors DROP CONSTRAINT distributors_pkey,
    ADD CONSTRAINT distributors_pkey PRIMARY KEY USING INDEX dist_id_temp_idx;

このようなサンプルがあったので分解して使いました。
①post_idの外部キー制約を外す
②Postテーブルのプライマリーキーをuuidに変更
③アソシエーションを結んでいるテーブルに先ほど追加したpost_uuidを外部キーとして設定する
この流れで実装しています。

### 4. indexを変更
```ruby
class ChangeIndex < ActiveRecord::Migration[7.1]
  def change
    # 既存のindexを削除
    # いいね
    remove_index :favorites, name: 'index_favorites_on_post_id'
    # タグの中間テーブル
    remove_index :post_tags, name: 'index_post_tags_on_post_id'

    # uuidをindexとして追加
    add_index :favorites, :post_uuid
    add_index :post_tags, :post_uuid
  end
end
```
`post_id`で`index`を設定していますが、プライマリーキーに合わせて`post_uuid`を`index`にします。

### 5. idカラムを削除
```ruby
class DeleteUserIdAndPostId < ActiveRecord::Migration[7.1]
  def change
    # post_idを削除
    # いいね
    remove_column :favorites, :post_id, :bigint
    # タグの中間テーブル
    remove_column :post_tags, :post_id, :bigint

    # postのidを削除
    remove_column :posts, :id, :bigint
  end
end
```
外部キー制約は外してあるのでどちらを先に消してもいい気がします。

### 6. idでやっている実装をuuidに変更
#### モデルファイルの修正
外部キーがidではなくuuidになったので明示的に指定します。
```ruby
class Post < ApplicationRecord
  belongs_to :user
  has_many :post_tags, primary_key: :uuid, foreign_key: :post_uuid, dependent: :destroy
  has_many :tags, through: :post_tags, source: :tag
  has_many :favorites, primary_key: :uuid, foreign_key: :post_uuid, dependent: :destroy
```
```ruby
class User < ActiveRecord::Base
  has_many :posts, dependent: :destroy
  has_many :favorites, dependent: :destroy
```
```ruby
class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :post, primary_key: :uuid, foreign_key: :post_uuid
end
```
```ruby
class PostTag < ApplicationRecord
  belongs_to :post, foreign_key: :post_uuid
```
```ruby
class Tag < ApplicationRecord
  has_many :post_tags, dependent: :destroy
  has_many :posts, primary_key: :uuid, foreign_key: :post_uuid, through: :post_tags
```
Postモデルを`has_many`しているモデル（User, Tag）に`primary_key: :uuid`と`foreign_key: :post_uuid`を明示します。
Postモデルに`belongs_to`しているモデル（Favorite, PostTag）には`foreign_key: :post_uuid`のみ追記します。

上記の流れでidをuuidに変更することができます。

# おまけ：uuidの短縮
DBにはuuidとして保存しつつ、実際のURLは短縮して表示したいです。
なぜならuuidは「8桁-4桁-4桁-4桁-12桁」の36文字からなり、URLにするととても長いです
（例：https:\//hogehoge.com/posts/123e4567-e89b-12d3-a456-426614174000）
なのでbase64で16進数に変換して短縮します。
```ruby
# post.rb
  # uuidの短縮
  def short_uuid
    # base64で短縮
    # - を削除したあと16進数に変換、パディングの=を削除
    Base64.urlsafe_encode64([uuid.delete('-')].pack("H*")).tr('=', '')
  end

  # 短縮uuidから検索
  def self.find_by_short_uuid(short_uuid)
    # base64でデコード
    # uuidは「8-4-4-4-12」の形式（例：550e8400-e29b-41d4-a716-446655440000）
    # なので16進数から変換して-を挿入
    decode_uuid = Base64.urlsafe_decode64(short_uuid).unpack1("H*").insert(8, '-').insert(13, '-').insert(18, '-').insert(23, '-')
    find_by(uuid: decode_uuid)
  end
```
これで22文字に短縮することができます。

# 終わりに
今回はPostモデルのみでしたが、開発ではUserモデルもuuidに変更したかったのでかなり複雑且つ量の多い修正になりました。
予めuuidで行う場合はあとから修正するより時間がかかっても先にやっておいたほうがいいと良い教訓になりました。
ご拝読いただきありがとうございました！