n対nの関係で中間テーブルにid以外のカラムを持たせ登録・編集を行う方法

Ruby On Railsでのアソシエーションの応用のお話。

下記のようなUserモデルとGroupモデルにn対nの関係を持たせ、UserにGroupの所有者情報を持たせたい時、中間テーブルに所有者情報を持たせた方がDB設計的になんとなく綺麗な気がする。 f:id:t1gerk1ngd0m:20190826225801p:plain

しかし、通常中間テーブルのレコードには直接アクセス出来ないため、モデルのアソシエーションで一工夫する必要がある。

n対nのアソシエーション

通常のn対nの関係は、以下のように表現する。まずはschema.rbから、

db/schema.rb

ActiveRecord::Schema.define(version: 2019_08_26_011656) do

  create_table "groups", force: :cascade do |t|
    t.string "name", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  create_table "users", force: :cascade do |t|
    t.string "name", null: false
    t.string "email", null: false
    t.string "password_digest", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  create_table "group_users", force: :cascade do |t|
    t.bigint "group_id"
    t.bigint "user_id"
    t.index ["group_id"], name: "index_group_users_on_group_id"
    t.index ["user_id"], name: "index_group_users_on_user_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

end

次にモデル。has_manythroughオプションに中間テーブルのシンボルを渡す事でn対nを表現している。

app/models/group.rb

class Group < ApplicationRecord
  has_many :group_users, dependent: :destroy
  has_many :users, through: :group_users
end

app/models/user.rb

class User < ApplicationRecord
  has_many :tasks, dependent: :destroy
  has_many :group_users, dependent: :destroy
end

app/models/group_user.rb

class GroupUser < ApplicationRecord
  belongs_to :group
  belongs_to :user
end

中間テーブルにカラムを持たせる方法

上記で作成したGroup_userにGroupの所有者、つまりownerを持たせたい。 その場合、Groupモデルに下記のように記述を加えていく。

app/models/group.rb

class Group < ApplicationRecord

  has_many :group_users, dependent: :destroy
  has_many :users, through: :group_users

  has_one :owner_group_user, -> {where(owner: true)}, class_name: 'GroupUser'
  has_one :owner_user, through: :owner_group_user, source: :user
end

# migrationファイルも忘れずに実行する
db/schema.rb

ActiveRecord::Schema.define(version: 2019_08_26_011656) do

  create_table "group_users", force: :cascade do |t|

# ~ 中略 ~

   t.boolean "owner", default: false, null: false

  end
end

中間テーブルであるowner_group_userでownerがtrueなレコードを取得し、それを使ってUserモデルからowner_userを取得している。 class_name: 'GroupUser'で用いるモデルはGroupUserであることを明記し、source: :userでUserモデルの情報を参照することを明記している。 これでViewで、

group.owner_user.name

と書くと、Groupの所有者名を取得することが出来る。 またController側でも、Viewから送られてくるparamsにowner_userの値をmergeすればそのままcreateupdateにparamsを渡す事で、owner_userを格納できる。

def group_params
  params.require(:group).permit(
    :name, 
    { user_ids: [] }
  ).merge(owner_user: current_user)
end

以上!

RailsでBootstrapのflashメッセージを良い感じに表示してみた

アプリケーションを作るさいに必ず多用するflashメッセージですが、これをBootstrapを使うと良い感じのデザインで作れます。 f:id:t1gerk1ngd0m:20190825222400p:plain

今回はhelperも使ってcssも切り替えながら、flashメッセージ部分を部分テンプレート化してみました。

コード

まずはflashメッセージのテンプレート部分

app/views/layouts/_flash.html.slim

- flash.each do |key, value|
  div class=("alert #{flash_class_for(key)}") role="alert" 
    button.close data-dismiss="alert"  ×
    = content_tag :div, value, class: key

次に共通レイアウト部分

app/views/layouts/application.html.slim

= render partial: "layouts/flashes", flash: flash

最後にhelperを実装

app/helpers/application_helper.rb

module ApplicationHelper

  def flash_class_for(key)
    case key
      when 'success' then 'alert-success'
      when 'failed'   then 'alert-danger'
    end
  end

end

以上で完成。

成果物

f:id:t1gerk1ngd0m:20190825223310p:plain f:id:t1gerk1ngd0m:20190825223328p:plain f:id:t1gerk1ngd0m:20190825223343p:plain f:id:t1gerk1ngd0m:20190825223357p:plain

参考

Twitter:Bootstrap getbootstrap.com

FactoryBotで中間テーブルのテストデータを用意する方法

FactoryBotでテストデータの中間テーブルを用意する方法がわからず、少し詰まったのでメモ。 f:id:t1gerk1ngd0m:20190824230419p:plain

想定

想定としてUserモデルとGroupモデルが存在し、n対nの関係であるとする。 中間テーブルのモデル名はGroup_userとする。

app/models/user.rb

class User < ApplicationRecord
  has_many :group_users, dependent: :destroy
  has_many :groups, through: :group_users
end

app/models/group.rb

class Group < ApplicationRecord
  has_many :group_users, dependent: :destroy
  has_many :users, through: :group_users
end

app/models/group_user.rb

class GroupUser < ApplicationRecord
  belongs_to :group
  belongs_to :user
end

FactoryBotの記述方法

レコードの関連付けを行う場合は色々な書き方があるが、ここではその一例を紹介する。 基本的には関連付けはtraitとcallbackを用いて記述する。

spec/factories/user.rb

FactoryBot.define do
  factory :user do
    sequence(:name) { |n| "test_user#{n}" }
    sequence(:email) { |n| "test_email#{n}@example.com" }
    sequence(:password) { "password" }

    trait :user_with_groups do
      after(:build) do |user|  # after(:build)とした場合、createの場合もcallbackが走る
        user.groups << build(:group)
      end
    end
  end
end

spec/factories/group.rb

FactoryBot.define do
  factory :group do

    equence(:name) { |n| "test_user#{n}" }

  end
end

上記の書き方をしておくと、userデータを作成後、traitでuserに紐づくgroupの作成をするかしないかを切り替える事が出来る。

# Userに紐づくGroupを作成する場合
user = create(:user, :user_with_groups)

# Use単体を作成する場合
user = create(:user)

これでUserに紐づくGroupの作成・非作成を切り替える事が出来た。

seedデータってどうやって入れる?seed-fuを使った便利なSeedデータ挿入法

railsアプリの初期データを入れ方として初期段階ではrake db:seedがあります。 でも、繰り返し使った場合同じデータが登録されたり、登録するデータの選別が出来ません。(コメントアウトを使えば出来るけどめんどくさくてやってられない) ここでseed-fuを使えば、未登録のデータだけ登録したり開発環境と本番環境のデータを分ける事も可能です。

f:id:t1gerk1ngd0m:20190823223750g:plain

インストール

rails5.0系以上では2.3を使います。

Gemfile

gem 'seed-fu', '~> 2.3'

ディレクトリ配置

seed_fuではdb直下にfixturesディレクトリを作成し、その下にseedファイル、もしくはディレクトリを配置します。 例えば開発環境と本番環境でのseedファイルを分けたい場合、下記の様に環境ごとにseedファイルを分ける事が出来ます。

db
  ├ fixtures
    ├ development
      ├ ○○.rb
      ├ ○○.rb
    ├ production
      ├ ○○.rb
      ├ ○○.rb
  ├ migrate

ファイルの実行

seedファイルが用意出来たと想定し、ファイルを実行しseedデータを入れていきます。(seedファイルの書き方は後述) rakeタスク実行時、FIXTURE_PATHオプションを付与する事でseedファイルを読み込むパスを指定できます。 また、FILTERオプションを付与する事でseedファイルを指定する事も出来ます。

# 通常の実行
rake db:seed_fu

# ファイルパスを指定する場合
rake db:seed_fu FIXTURE_PATH=path/to/fixtures

# seedファイルを指定する場合
rake db:seed_fu FILTER=users,articles  # 複数指定する場合はカンマ区切り

seedファイルの記述の仕方

seedファイルの書き方は下記のようになる。下記を実行するとデータが1つ挿入される。

db/fixtures/user.rb

User.seed do |s|
  s.name = "田中健太郎"
  s.email = "aaa@gmail.com"
  s.password = "123456"
end

しかし、実際は大量のデータを扱う事になるので、下記のように記述する事が多いかもしれない。 下記を実行するとデータが10個挿入される。

db/fixtures/user.rb

10.times do |n|
  User.seed do |s|
    s.name = "テスト名前#{n}"
    s.email = "aaa#{n}@gmail.com"
    s.password = "123456"
  end
end

seedデータ実行順

seedデータは上から順に実行されるので、モデルをネストさせている場合ファイル名通りに読み込まれ途中でエラーを吐く可能性がある。 そこで、一番上のモデルからファイル名の頭にナンバリングしておくと良い。

db
  ├ fixtures
    ├ 01_user.rb
    ├ 02_group.rb
    ├ 03_task.rb
  ├ migrate

こうする事で、ナンバリングされた順でファイルが読み込まれるため関連付けされていない事によるエラーが起こらない。

似ているようで実は違う!?dependent: :destroyとdependent: :delete_all

親モデルに紐付く子モデルがある場合、has_many: で関連付けしただけだと親モデルを削除すると子モデルが孤立してしまうため、通常はdependentオプションでdestroyを指定し、親モデルを削除するとそれに紐付く子モデルも削除されるように設定する。 しかし、実は子モデルを削除するには:destoryだけでなく:delete_allも存在する。

ここにUserモデルに紐付くTaskモデルが存在するとする。

app/models/User.rb

class User < ApplicationRecord

  has_many :tasks, dependent: :destroy  # dependent: :destroyを使用
  has_many :tasks, dependent: :delete_all  # dependent: :delete_allを使用

end
app/models/Task.rb

  belongs_to :user  # ここではoptionは指定しない

end

上記のように、親モデルのhas_manyのオプションとしてdependent:を指定する。 :destroyを指定した場合、子モデルの削除はActiveRecordを介してSQL文が発行されるのに対し、:delete_allを指定した場合はActiveRecordを介さず直接SQL文が発行される。

発行されるSQL

例えばid: 1のUserにid: 1,2,3,4,5の5つのTask紐付いている場合

:destroyの場合

# Userを選択
SELECT "tasks".* FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]

# Taskをuser_idから一つずつ選択し、削除
SELECT "tasks".* FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]
DELETE FROM "tasks" WHERE "tasks"."id" = $1  [["id", 1]]
SELECT "tasks".* FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]
DELETE FROM "tasks" WHERE "tasks"."id" = $1  [["id", 2]]
SELECT "tasks".* FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]
DELETE FROM "tasks" WHERE "tasks"."id" = $1  [["id", 3]]
SELECT "tasks".* FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]
DELETE FROM "tasks" WHERE "tasks"."id" = $1  [["id", 4]]
SELECT "tasks".* FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]
DELETE FROM "tasks" WHERE "tasks"."id" = $1  [["id", 5]]

# 最後にUserを削除
DELETE FROM "users" WHERE "users"."id" = $1  [["user_id", 1]]
delete_allの場合

# Taskをuser_idから一括削除
DELETE FROM "tasks" WHERE "tasks"."user_id" = $1  [["user_id", 1]]

# Userを削除
DELETE FROM "users" WHERE "users"."id" = $1  [["id", 1]]

上記のように、:destroyの場合はuser_idから一つずつtaskを選択して削除しているのに対し、:delete_allではuser_idから一括してtaskを削除している。 大量のデータを扱う場合を想定しているなら、:delete_allを使った方が良さそう。

【ActiveAdminにご用心】ActiveAdmin導入時のassetファイル汚染防止法

管理画面を簡単に作ることの出来るactiveadminですが、そのまま何も考えずに導入して通常ページのデザインが崩れてしまいました。

f:id:t1gerk1ngd0m:20190821214353p:plain

原因

これはactiveadminのCSSJavascriptが管理画面以外でも読み込まれる事が原因です。

rails generate active_admin:installを実行した際、app/assets/javascripts/active_admin.jsapp/assets/stylesheets/active_admin.cssが生成されますが、こいつらをapplication.jsapplication.cssが読み込んじゃってます。 なので、application.jsapplication.cssではactiveadmin系のassetファイルを読み込まないよう設定します。

対策

①まず、下記のようなフォルダ構成にします。

javascripts
  ├ admin
    ├ active_admin.js.coffee
  ├ main
    ├ その他のjavascriptファイル
  ├ application.js
stylesheets
  ├ admin
    ├ active_admin.css
  ├ main
    ├ その他のcssファイル
  ├ application.css

②次に、jsとcssのapplicationファイルの//= require_tree .を、//= require_tree ./mainに変更します。

③続いて、下記を追加します。

config/initializer/active_admin.rb

config.clear_stylesheets!
config.register_stylesheet 'admin/active_admin.css'

config.clear_javascripts!
config.register_javascript 'admin/active_admin.js'

Herokuを使用している場合は下記も追加します。

config/application.rb

config.assets.precompile += %w[admin/active_admin.css admin/active_admin.js]

④最後に、サーバを再起動してassetファイルがきちんと読み込まれていたら完了です。

f:id:t1gerk1ngd0m:20190821220456p:plain

LaravelでSQLiteを使う際のmigrationの忘備録

LaravelでDBをSQLite3を使った時に最初のDB設定でハマったので忘備録。

LaravelのデフォルトのDBはMYSQL

LaravelではデフォルトのDBがMYSQLなので、それ以外のDBを使う際は設定を変えないといけない。

/.env

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret

デフォルトでこうなっているので、以下に変更。
/.env

DB_CONNECTION=sqlite

sqliteデータベースの作成

次に、ルートディレクトリ/databaseディレクトリの中にdatabase.sqliteファイルを作成

// アプリケーションのルートにいる状態で
touch database/database.sqlite

これは/config/database.phpファイル内で、sqliteの設定箇所にdatabase.sqliteという名前で設定されているため。
/config/database.php

'sqlite' => [
    'driver' => 'sqlite',
    'url' => env('DATABASE_URL'),
    'database' => env('DB_DATABASE', database_path('database.sqlite')),
    'prefix' => '',
    'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],

最後にmigrateを実行

ここまで出来ればmigrationを実行して完了。

php artisan migrate