Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
5
Help us understand the problem. What is going on with this article?
@takamuu

【Rails6】ER図とテーブル設計からmodelとmigrateを素早く作成する手順

はじめに

Qiita初投稿です。
2021年3月から実際の開発案件に参加させて頂き、案件を通じて学んだ内容を投稿したいと思います。
modelやmigrateを作成する情報を多く目にしますが、実際にコードを書いて実装した際、作成手順で迷ったり、migrateの作成し直しを何度も行ったりした点を備忘録的にまとめました。
RubyonRails初学者の為、間違っている情報を記載している場合はご指摘いただけますと幸いです。

開発環境

  • macOS Catalina バージョン 10.15.7
  • Ruby 2.7.2
  • Rails 6.1.1
  • mysql Ver 8.0.23

modelとmigrateを作成するまでの流れ

開発において、要件定義やER図・テーブル設計は、より高度な知識や理解を必要とする作業だと思いますが、私の場合は基本設計が既にできあがった状態から参加させて頂きました。
そのため、ここではER図とテーブル設計が完成した状態から、素早く(約半日くらいで)modelとmigrateを作成し、seedsで動作確認を行うまでの流れをまとめたいと思います。
下記のER図とテーブル設計は簡略化したものですが、基本的な項目は網羅するよう記載したつもりです。

作業の流れ

  1. ER図
  2. Userテーブル
      テーブル情報整理
      migrateとmodelを作成
  3. Productテーブル
      テーブル情報整理
      migrateとmodelを作成
  4. Contractテーブル
      テーブル情報整理
      migrateとmodelを作成
  5. seedsでDBテストデータ作成
  6. バリデーションが正しく機能するかテストする
  7. 正しく関連付け(アソシエーション)ができているかをテストする
  8. 補足

1. ER図

image.png
※IE記法

ER図から、モデルの関係性とKeyの指定を把握

  • UserモデルとContractモデルは、1対0以上(1対多)の関係
  • ProductモデルとContractモデルは、1対0以上(1対多)の関係
  • PKやFKについては、テーブル設計と比較して一致確認
  • PK(PRIMARY KEY/主キー)
  • UK(UNIQUE KEY/ユニーク制約キー)
  • FK(FOREIGN KEY/外部キー)

2. Userテーブル

カラム カラムの説明 キー index not null カラムの型 default 備考
id id PK bigint
name ユーザー名   string    
email Eメール UK string 文字数を255文字までに制限する
Emailアドレスのフォーマットを検証
password パスワード string     

※ PK = Primary Key UK = Unique Key
※ ● 暗黙的に設定 ⚪ 明示的に設定する

上記Userテーブルの情報整理(テーブル情報をコードに置き換えて整理)

migrate model
キー【PK】 暗黙的にindex,not null設定が入る
キー【UK】 index: { unique: true }
または
add_index :users, :email, unique: true を書く 
uniqueness: true
index index: true を設定 または add_index を書く (PKは自動設定)
not null null: false (PKとUKは自動設定) presence: true
カラムの型 name:string  email:string password:string 
(PKはbigint型が自動で入る)
備考                  length: { maximum: 255 }
format: { with: VALID_EMAIL_REGEX }
VALID_EMAIL_REGEX = /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i.freeze

一意制約は、アプリ側(model)だけでよいのか、DB側(migrate)もかけるのか迷いましたが、両方にかけるのが正解

参考:Railsチュートリアル6章:「Active Recordはデータベースのレベルでは一意性を保証していないという問題」を参照
参考:Rails 一意性制約のかけ方

Userテーブルのmodelとmigrate作成

上記で整理した内容を実際にターミナルに入力
$ rails g model モデル名 カラム名:型
各カラムが作成されるところまではターミナルに入力し、追加設定はmodel、migrateに直接入力、と分けて作業するのがわかりやすいと思います

$ rails g model User name:string email:string password:string
Running via Spring preloader in process 78395
      invoke  active_record
      create    db/migrate/2021xxxxxxxxxx_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml

modelとmigrateが作成される(テストはRSpecで書くのであれば、削除)

model/user.rb
class User < ApplicationRecord
end
migrate/2021xxxxxxxxxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email
      t.string :password

      t.timestamps
    end
  end
end

テーブル条件をDB側(migrate)に追加する

  • null: false
  • add_index :users, :email, unique: true

※一意制約はindex: { unique: true }でもOK
あとでschema.rbと見比べたりするのであれば、add_index:unique: trueとしたほうが見やすい

migrate/2021xxxxxxxxxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :users do |t|
      t.string :name,     null: false
      t.string :email,    null: false
      t.string :password, null: false

      t.timestamps
    end

    add_index :users, :name
    add_index :users, :email, unique: true
  end
end

設定し忘れがないかschema.rbを確認

$ rails db:migrate
db/schema.rb
ActiveRecord::Schema.define(version: 2021_05_xx_xxxxxx) do

  create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "name", null: false
    t.string "email", null: false
    t.string "password", null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["name"], name: "index_users_on_name"
  end

end

テーブル条件をmodelに追加する

  • presence: true
  • uniqueness: true
model/user.rb
class User < ApplicationRecord
  validates :name,     presence: true
  validates :email,    presence: true, uniqueness: true
  validates :password, presence: true
end
  • Emailの255文字制限を追加 length: { maximum: 255 }
  • 正規表現を定数に代入 VALID_EMAIL_REGEX = /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i.freeze
  • フォーマット検証を追加 format: { with: VALID_EMAIL_REGEX }
models/user.rb
class User < ApplicationRecord
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.freeze
  validates :name,     presence: true
  validates :email,    presence: true, uniqueness: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }
  validates :password, presence: true
end

関連付け(アソシエーション)を追加

UserとContractは1:0以上(1:多)なので、has_many :contract, dependent: :destroyを追加しておく
dependent: :destroyはUserを削除した時、関連付けしている契約(Contract)も同時に削除する

models/user.rb
class User < ApplicationRecord
  has_many :contracts, dependent: :destroy

  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.freeze
  validates :name,     presence: true
  validates :email,    presence: true, uniqueness: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }
  validates :password, presence: true
end

ここで、Userのmigrate、modelをすぐにテストする場合は、5. seedsでDBテストデータ作成を参考にテストデータを作成してください
(この状態ではアソシエイトは動きません)

3. Productテーブル

カラム カラムの説明 キー index not null カラムの型 default 備考
id id PK bigint
product_name 商品名       string    
product_price 商品の価格    integer 0

※ PK = Primary Key
※ ● 暗黙的に設定 ⚪ 明示的に設定する

上記Productテーブルの情報整理(テーブル情報をコードに置き換えて整理)

migrate model
キー【PK】 暗黙的にindex,not null設定が入る
index add_index を設定 または index: true を書く (PKは自動設定)
not null null: false (PKは自動設定) presence: true
カラムの型 product_name:string  product_price:integer 
(PKはbigint型が自動で入る)
default default: 0

Productテーブルのmodelとmigrate作成

上記で整理した内容を実際にターミナルに入力
Productテーブル情報を$ rails g model モデル名 カラム名:型にあてはめる

 $ rails g model Product product_name:string product_price:integer
Running via Spring preloader in process 85812
      invoke  active_record
      create    db/migrate/2021xxxxxxxxxx_create_products.rb
      create    app/models/product.rb
      invoke    test_unit
      create      test/models/product_test.rb
      create      test/fixtures/products.yml
models/product.rb
class Product < ApplicationRecord
end
db/migrate/2021xxxxxxxxxx_create_products.rb
class CreateProducts < ActiveRecord::Migration[6.1]
  def change
    create_table :products do |t|
      t.string :product_name
      t.integer :product_price

      t.timestamps
    end
  end
end

テーブル条件をDB側(migrate)に追加する

  • null: false
  • default: 0
  • add_index :products, :product_name
migrate/2021xxxxxxxxxx_create_products.rb
class CreateProducts < ActiveRecord::Migration[6.1]
  def change
    create_table :products do |t|
      t.string  :product_name,  null: false
      t.integer :product_price, null: false, default: 0

      t.timestamps
    end

    add_index :products, :product_name
  end
end

設定し忘れがないかschema.rbを確認

$ rails db:migrate
db/schema.rb
ActiveRecord::Schema.define(version: 2021_05_xx_xxxxxx) do

  create_table "products", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "product_name", null: false
    t.integer "product_price", default: 0, null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["product_name"], name: "index_products_on_product_name"
  end

  create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "name", null: false
    t.string "email", null: false
    t.string "password", null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["name"], name: "index_users_on_name"
  end

end

テーブル条件をmodelに追加する

  • presence: true
models/product.rb
class Product < ApplicationRecord
  validates :product_name,  presence: true
  validates :product_price, presence: true
end

関連付け(アソシエーション)を追加

ProductとContractは1:0以上(1:多)なので、has_many :contractを追加しておく
商品(Product)を削除した時に契約(Contract)を同時に削除する必要はないので、dependent: :destroyは不要

models/product.rb
class Product < ApplicationRecord
  has_many :contracts

  validates :product_name,  presence: true
  validates :product_price, presence: true
end

ここで、Productのmigrate、modelをすぐにテストする場合は、5. seedsでDBテストデータ作成を参考にテストデータを作成してください
(この状態ではアソシエイトは動きません)

4. Contractテーブル

カラム カラムの説明 キー index not null カラムの型 default 備考
id id PK bigint
user_id ユーザーID FK bigint 外部キー(Userテーブルのidと紐付ける)
product_id 商品ID FK bigint 外部キー(Productテーブルのidと紐付ける)
product_name 商品名 string *Contract作成時、product_nameから転記
contract_money 契約金 integer 0
contract_status 契約ステータス integer enum(reservation:0, confirm:1)

※ PK = Primary Key FK = Foreign Key
※ ● 暗黙的に設定 ⚪ 明示的に設定する

上記Contractテーブルの情報整理(テーブル情報をコードに置き換えて整理)

migrate model
キー【PK】 暗黙的にindex,not null設定が入る
キー【FK】 user:references product:references (暗黙的にindex,not null設定が入る)
詳細には、references とすると自動的に null: false と foreign_key: true が migrate に追記される 
index: true は記載されないが、index設定が自動的に db/schema.rb に入る
index add_index :contracts, :product_name を設定 または index: true を書く (PK,FKは自動設定)
not null references で null: false 設定 presence: true を設定
カラムの型 product_name:string  contract_money:integer contract_status:integer 
(PKはbigint型が自動で入る)
default default: 0
備考 enum contract_status: { reservation: 0, confirm: 1, }

Contractテーブルのmodelとmigrate作成

上記で整理した内容を実際にターミナルに入力
Contractテーブル情報を$ rails g model モデル名 カラム名:型にあてはめる

$ rails g model Contract user:references product:references product_name:string contract_money:integer contract_status:integer
Running via Spring preloader in process 79699
      invoke  active_record
      create    db/migrate/2021xxxxxxxxxx_create_contracts.rb
      create    app/models/contract.rb
      invoke    test_unit
      create      test/models/contract_test.rb
      create      test/fixtures/contracts.yml

modelとmigrateが作成される(テストはRSpecで書くなら、削除)

model/contract.rb
class Contract < ApplicationRecord
  belongs_to :user
  belongs_to :product
end
migrate/2021xxxxxxxxxx_create_contracts.rb
class CreateContracts < ActiveRecord::Migration[6.1]
  def change
    create_table :contracts do |t|
      t.references :user, null: false, foreign_key: true
      t.references :product, null: false, foreign_key: true
      t.string :product_name
      t.integer :contract_money
      t.integer :contract_status

      t.timestamps
    end
  end
end

テーブル条件をDB側(migrate)に追加

  • index add_index :contracts, :product_name
  • not null設定 null: false
  • default設定 default: 0

ついでに行も揃えると見やすい

migrate/2021xxxxxxxxxx_create_contracts.rb
class CreateContracts < ActiveRecord::Migration[6.1]
  def change
    create_table :contracts do |t|
      t.references :user,           null: false, foreign_key: true
      t.references :product,        null: false, foreign_key: true
      t.string     :product_name,   null: false
      t.integer    :contract_money, null: false, default: 0
      t.integer    :contract_status

      t.timestamps
    end

    add_index :contracts, :product_name
  end
end

設定漏れがないかschema.rbを確認

$ rails db:migrate
db/schema.rb
ActiveRecord::Schema.define(version: 2021_05_26_081531) do

  create_table "contracts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.bigint "product_id", null: false
    t.string "product_name", null: false
    t.integer "contract_money", default: 0, null: false
    t.integer "contract_status"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["product_id"], name: "index_contracts_on_product_id"
    t.index ["product_name"], name: "index_contracts_on_product_name"
    t.index ["user_id"], name: "index_contracts_on_user_id"
  end

  create_table "products", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "product_name", null: false
    t.integer "product_price", default: 0, null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["product_name"], name: "index_products_on_product_name"
  end

  create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "name", null: false
    t.string "email", null: false
    t.string "password", null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["name"], name: "index_users_on_name"
  end

  add_foreign_key "contracts", "products"
  add_foreign_key "contracts", "users"
end

今回のテーブル設計にはありませんが、以下はよくある?設定
 t.references :userの参照先テーブル名がカラム名とは違う場合 foreign_key: { to_table: :customers }と参照先を指定する

migrate/2021xxxxxxxxxx_create_contracts.rb
t.references :user, null: false, foreign_key: { to_table: :customers }

テーブル条件をmodelに追加

  • presence: true 追加
  • enum追加
model/contract.rb
class Contract < ApplicationRecord
  belongs_to :user
  belongs_to :product

  validates :user,            presence: true
  validates :product,         presence: true
  validates :product_name,    presence: true
  validates :contract_money,  presence: true
  validates :contract_status, presence: true

  enum contract_status: {
    reservation: 0,
    confirm: 1,
  }
end

関連付け(アソシエーション)を追加

UserモデルとContractモデルの「1対0以上(1対多)」の関係と、ProductモデルとContractモデルの、「1対0以上(1対多)」の関係をmodelに反映

model/contract.rb
class Contract < ApplicationRecord
  belongs_to :user
  belongs_to :product

  validates :user,            presence: true
  validates :product,         presence: true
  validates :product_name,    presence: true
  validates :contract_money,  presence: true
  validates :contract_status, presence: true

  enum contract_status: {
    reservation: 0,
    confirm: 1,
  }
end
models/user.rb
class User < ApplicationRecord
  has_many :contracts, dependent: :destroy

  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.freeze
  validates :name,     presence: true
  validates :email,    presence: true, uniqueness: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }
  validates :password, presence: true
end
model/product.rb
class Product < ApplicationRecord
  has_many :contracts

  validates :product_name,  presence: true
  validates :product_price, presence: true
end

これで、関連付けされた別テーブルのデータが使用できるようになるので、次項以下で確認します
migrateとmodelの作成作業はひとまず完了です

5. seedsでDBテストデータ作成

db/seeds.rbでテストデータを作成する目的は、以下二点です
①DBが正常に保存できるかを確認する
②テーブル設計どおりにモデルのバリデーション等が機能するか確認する

db/seeds.rb
user1 = User.create!(name: "akagi", email: "akagi@example.com", password: "password")
user2 = User.create!(name: "uozumi", email: "uozumi@example.com", password: "password")
user3 = User.create!(name: "anzai", email: "anzai@example.com", password: "password")

puts "userデータの投入に成功しました!"

product1 = Product.create!(product_name: "サッカーボール", product_price: 10000 )
product2 = Product.create!(product_name: "バスケットボール", product_price: 15000)

puts "productデータの投入に成功しました!"

Contract.create!(user_id: user1.id, product_id: product1.id, product_name: product1.product_name, contract_money: 500000, contract_status: 0)
Contract.create!(user_id: user2.id, product_id: product2.id, product_name: product2.product_name, contract_money: 1000000, contract_status: 1)
Contract.create!(user_id: user1.id, product_id: product2.id, product_name: product2.product_name, contract_money: 500000, contract_status: 0)

puts "Contractデータの投入に成功しました!"

テストデータを作成するコマンドを入力

$ rails db:seed

※一度、seedデータを作成し終えている場合は、$ rails db:resetを実行

うまく保存できない(エラーが出る)場合は、binding.pryseeds.rbに入れてどこがうまく行かないのか確認しながら修正する

6. バリデーションが正しく機能するかテストする

全てのバリデーションテストはRSpecのモデルスペックで実装することを想定し、ここでは、主な項目だけをテストしていきます。

ターミナル
$ rails console

# user4を正常なデータとして作成することができる(seeds.rbが動作する時点で確認済ですが…)
> user4 = User.create(name: "mitui", email: "mitui@example.com", password: "password")

# Emailの正規表現が機能しているか確認
> user4.update(email: "mitui@..example.com")
=> false

# エラーメッセージも確認する
> user4.errors.full_messages
=> ["Email is invalid"]

# 文字制限が機能している確認
> user4.update(email:" #{"m" * 255}@example.com")
=> false
> user4.errors.full_messages
=> ["Email is too long (maximum is 255 characters)", "Email is invalid"]

# uniqueが機能しているか確認
> user4.update(email: "akagi@example.com")
=> false
> user4.errors.full_messages
=> ["Email has already been taken"]

7. 正しく関連付け(association)ができているかをテストする

ターミナル
$ rails console

# Userが正常に保存されているか確認
> user1, user2, user3 = User.all

> user1.name
=> "akagi"

# Userモデルの has_many :contracts, dependent: :destroy が関連付けできているか確認
> user1.contracts


# user1のcontractsを全て取得する
> user1.contracts.all

# user1のcontractsの1つ目のproduct_nameを取得する
> user1.contracts[0].product_name

# productモデルの has_many :contracts が関連付けできているか確認
> product2 = Product.second

# productオブジェクトから、contractと関連付けされたuserを取得する
> product2.contracts[1].user

# user3.contracts.create!でuser3とproduct2のContractのインスタンスを作成して保存
> user3.contracts.create!(product_id: product2.id, product_name: product2.product_name, contract_money: 750000, contract_status: 0)

# 作成保存できているか、Contractを確認
> Contract.last

これで、DB周辺のmodelとmigrateの確認も完了
あとは必要な機能要件に合わせて、viewsとcontrollersの作成に入る

参考:Railsガイド Active Record

8. 補足

途中でカラムを追加したくなった時の手順で迷ったので補足
例えば、db/migrate/2021xxxxxxxxxx_create_contracts.rbに contract_titleをうっかり作成し忘れていた事に途中で気づいたような時、AddColumnを多用するのはよくない(自論)と思いますので、githubのmasterにマージする前後で作業を分けました

githubにブランチをpushしてmasterにマージする前

ターミナル
# どこまで戻すか確認する
$ rails db:migrate:status

database: qiita_posting

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20210526055004  Create products
   up     20210526065824  Create users
   up     20210526081531  Create contracts

# 必要なところまでdbを戻す(以下は一つだけ戻す書き方)
$ rails db:rollback

# もし、2つ戻すなら、rails db:rollback STEP=2 と書く

db/migrateにカラム(contract_title)を追加する

db/migrate/2021xxxxxxxxxx_create_contracts.rb
class CreateContracts < ActiveRecord::Migration[6.1]
  def change
    create_table :contracts do |t|
      t.references :user,        null: false, foreign_key: true
      t.references :product,     null: false, foreign_key: true
      t.string :product_name,    null: false
      t.string :contract_title,  null: false
      t.integer :contract_money, null: false, default: 0
      t.integer :contract_status

      t.timestamps
    end

    add_index :contracts, :product_name
  end
end

rails db:migrateを再度実行する

githubにブランチをpushしてmasterにマージした後のカラム追加

$ rails g migration AddColumnToContracts contract_title:string
# 変更内容
  def change
    add_column :contracts, :contract_title, :string, null: false
  end
end

参考:【Rails】 マイグレーションファイルを徹底解説!

数ある追加・修正パターンの一部ではありますが、備忘録的にまとめてみました。
私にとっては貴重な初案件であり、参加の機会をくださった方々、また技術不足の私にspatial.chatやzoom、Slackでサポート頂いた開発メンバーの方々に、この場を借りて厚く御礼申し上げます。
また、記載している内容の間違いやよりよい手順等ありましたら、ご指摘頂けますと幸いです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
5
Help us understand the problem. What is going on with this article?