【Rails】deviseを途中から導入してユニーク制約エラーとなった話
head_image

当ブログをRailsで作成する過程で、bcryptのみを使った簡単なユーザ認証を実装したところからdeviseを使うように切り替えたのですが、その際に既存のUserモデルにemailカラムが存在しなかったためにユニーク制約違反となり、少し困ったのでここに記します。

環境

ruby '2.6.7'
gem 'rails',  '6.1.4'
gem 'bcrypt', '4.8.1'  #既存のログイン機能にて使用

devise導入前の状況について

  • Userモデルは既に存在しているがemailカラムは持っていない
  • Userテーブルに二人分以上のレコードあり
  • パスワードはbcryptのGemにより暗号化

deviseを途中から実装する前の認証機能についてはこんな感じ。
名前とパスワードで認証するようにしており、device導入前はemailカラムがない状態でした。

device導入プロセスとエラー事象

導入自体は非常に簡単で、いくつかのコマンドを叩くだけです。
(エラー事象に飛びたい方はこちらから)

1. deviseのGemをインストール

gem 'devise' #追加
$ bundle install

2. devise のインストール

下記のコマンドを入力してdeviceをインストールし、deviseを利用可能な状態にします。

$ rails g devise:install

以下のような表示が出れば、無事インストールが成功。

$ rails g devise:install                                
Running via Spring preloader in process 84734
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml
===============================================================================

Depending on your application's configuration some manual setup may be required:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

     * Required for all applications. *

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"

     * Not required for API-only Applications *

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

     * Not required for API-only Applications *

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views

     * Not required *

===============================================================================

3. Userモデルの更新

以下のコマンドによりUserモデルにログイン機能を付与します。
今回はUserモデルが既に存在していますが、Userモデルが存在しなければこれにより生成されます。

$ rails g devise User

コマンドを叩くと以下のように、Userモデルをログイン機能に対応するためのマイグレーションファイルが作成(create)され、Userモデルにログイン関連の機能が付与(insert)されます。
また、ログイン画面などのユーザ認証画面へのルーティングも自動で追加してくれます。

$ rails g devise User
Running via Spring preloader in process 84845
      invoke  active_record
      create    db/migrate/20220610115416_add_devise_to_users.rb
      insert    app/models/user.rb
       route  devise_for :users

4. DBに反映させる

最後にUserモデルのマイグレーションファイルをDBに反映させます。
これが正常終了すれば導入は完了。

$ rails db:migrate

なのですが、今回この rails db:migrate にて以下のエラーが起こってしまいました。

$ rails db:migrate 
Running via Spring preloader in process 86090
== 20220725115416 AddDeviseToUsers: migrating =================================
-- change_table(:users)
   -> 0.0060s
-- add_index(:users, :email, {:unique=>true})
rake aborted!
StandardError: An error has occurred, this and all later migrations canceled:

SQLite3::ConstraintException: UNIQUE constraint failed: users.email
/Users/shige/development/blog_app/db/migrate/20220610115416_add_devise_to_users.rb:40:in `up'
-e:1:in `<main>'

Caused by:
ActiveRecord::RecordNotUnique: SQLite3::ConstraintException: UNIQUE constraint failed: users.email
/Users/shige/development/blog_app/db/migrate/20220610115416_add_devise_to_users.rb:40:in `up'
-e:1:in `<main>'

Caused by:
SQLite3::ConstraintException: UNIQUE constraint failed: users.email
/Users/shige/development/blog_app/db/migrate/20220610115416_add_devise_to_users.rb:40:in `up'
-e:1:in `<main>'
Tasks: TOP => db:migrate
(See full trace by running task with --trace)

エラー内容は、Userテーブルのemailカラムに対してユニーク制約の付与が失敗しましたというもの。
ユニーク制約とは、そのテーブルの中で同じ値を持つレコードがあってはならないという制約のことです。

原因

作成されたマイグレーションファイルの中で、emailカラムを新規に追加した後にユニーク制約を付与しているのですが、Userテーブルの既存のレコードにぶら下がってできるemailの値は全て空文字列("")になるため、ユニーク制約を付与するときに矛盾してしまいエラーが発生してしまっていました。

UserTable

詳細

マイグレーションファイル中のdefaultオプションにより、emailの初期値は空文字列("")に設定しているため、既存のレコードにぶら下がるemailの値は全て空文字列になります。

# この記載で初期値を空文字に設定
t.string :email,  null: false, default: ""

今回Userテーブルに複数のレコードが存在していたため、空文字列の値を持つemailが複数になってしまい、ユニーク制約を付与する前に違反した状態になっていたため失敗したという事象でした。

# この記載でemailカラムにユニーク制約を付与
add_index :users,  :email, unique: true

解決方法

解決方法としては、emailカラムにユニーク制約を付与する記載部分のみを別のマイグレーションファイルとして分けてあげて、emailカラムに別々のデータを入れた後にユニーク制約を付与すればOKです。

① 元のマイグレーションファイルのユニーク制約付与の部分をコメントアウトか削除する。

20220610115416_add_devise_to_users.rb
# frozen_string_literal: true

class AddDeviseToUsers < ActiveRecord::Migration[6.1]
  def self.up
    change_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      # t.integer  :sign_in_count, default: 0, null: false
      # t.datetime :current_sign_in_at
      # t.datetime :last_sign_in_at
      # t.string   :current_sign_in_ip
      # t.string   :last_sign_in_ip

      ## Confirmable
      # t.string   :confirmation_token
      # t.datetime :confirmed_at
      # t.datetime :confirmation_sent_at
      # t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at


      # Uncomment below if timestamps were not included in your original model.
      # t.timestamps null: false
    end

    # add_index :users, :email,                unique: true  #ここの記載をコメントアウトする
    add_index :users, :reset_password_token, unique: true
    # add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end

  def self.down
    # By default, we don't want to make any assumption about how to roll back a migration when your
    # model already existed. Please edit below which fields you would like to remove in this migration.
    raise ActiveRecord::IrreversibleMigration
  end
end


② ここで一度マイグレーションファイルをDBに反映する。

$ rails db:migrate

これによりUserテーブルに、初期値が空文字("")のemailカラムが付与されましたので、追加されたemailカラムそれぞれにユニークなデータを入れてあげましょう。

というところで、また別の問題が発生してしまいました。

以下のようにrailsコンソールから、Userテーブルのレコードを取得しようとすると引数エラーになってしまいました。

$ rails c
Running via Spring preloader in process 7119
Loading development environment (Rails 6.1.4.4)
irb(main):001:0> User.all
   (2.1ms)  SELECT sqlite_version(*)
  User Load (0.5ms)  SELECT "users".* FROM "users"
Hirb Error: wrong number of arguments (given 0, expected 1)
    /Users/shige/.rbenv/versions/2.6.7/lib/ruby/gems/2.6.0/gems/devise-4.8.1/lib/devise/models/database_authenticatable.rb:204:in `password_digest'

原因は、bcryptで使用していた password_digestカラムがdeviseに備わるパスワードを扱う機能とバッティングしてしまいエラーとなるようです。
(参考サイト)

そのため不要なpassword_digestカラムは削除してあげる必要があります。
ついでにUserモデルから「has_secure_password」の記述も悪さをするので消してあげしょう。

③ Userテーブルからpassword_digestカラムを削除する。

カラムを削除するマイグレーションファイルを作成する。

$ rails g migration RemoveColumnFromUser

またマイグレーションファイルを下記のように記述する。

class RemoveColumnFromUser < ActiveRecord::Migration[6.1]
  def change
    remove_column :users, :password_digest, :string
  end
end

カラム削除の変更をDBに反映させる。

$ rails db:migrate


④ emailカラムそれぞれにユニークなデータを入れる。

railsコンソールなどにより、既存のレコードのemailカラムにそれぞれ違う値のデータを登録します。
またencrypted_passwordカラムもNOT NULL制約になっているので一緒にデータを登録しましょう。

UserTable

⑤ ユニーク制約付与部分のマイグレーションファイルを作成してDBに反映させる。

最後に、ユニーク制約を付与する記載部分のマイグレーションファイルを作成して、同様にDBに反映させればエラーの対処は完了です。

カラムを変更するマイグレーションファイルを作成する。

$ rails g migration AddIndexToUser

①で取り除いた部分をマイグレーションファイルに記載する。

class AddIndexToUser < ActiveRecord::Migration[6.1]
  def change
    add_index :users, :email, unique: true  #コメントアウトした部分を記載する
  end
end

変更をDBに反映させる。

$ rails db:migrate

ここまで終われば、deviseの導入は完了です。

まとめ

  1. 既存のUserモデルがemailカラムを持っていない場合は、rails db:migrateでユニーク制約付与時にエラーとなるため、マイグレーションファイルを分ける。
  2. password_digestカラムがdeviseのパスワード認証機能にバッティングするため、削除する。
  3. Userモデルのhas_secure_passwordメソッドの記載を削除する。

今回、deviseを途中から実装する際に行った内容を簡単にまとめるとこんな感じです。

deviseを導入する際に発生したユニーク制約のエラーに対して自分が実際に行った対処方法を記載しましたが、本対応だと既存ユーザの登録されたパスワードを引き継ぐことができないので注意が必要です。
既存のユーザのパスワードを引き継ぎたい場合は、元のpassword_digestカラム名をencrypted_passwordに変更する方法があります。こちらのサイトを参考にしてください。

あくまでも、自分が行ったプロセスが少しでも参考になればという思いで書きました。
以上!


ABOUT

気ままにアウトプットをする場所です。

このブログについて
CATEGORY
TOP