WeBlog

Webに関する情報を中心に発信してるブログ

【Rails】validates :user_id, uniqueness: { scope: :board_id }のついて

ブックマーク機能やいいね機能などで、複数のカラムに対して一意制約を設けるをしたい場合があります。

要は、複数のカラムの組み合わせがユニークであってほしい場面です。

例えば、bookmarksテーブルにuser_idカラムとboard_idというカラムがあるとします。

その2つのカラムの組み合わせが重複しないようにバリデーションを貼るにはどうすればいいでしょうか?

この組み合わせのユニークを担保するために、Railsアプリケーション側でユニークであることを確認するvalidate処理を設けます。

UserとBookmarkとBoardモデルの関係性

今回はUserとBookmarkとBoardモデルが存在するという前提で書きます。

この前提において、一人のユーザーは、自分が作成した掲示板以外をたくさんブックマークすることができます。

反対に、1つの掲示板はたくさんのユーザーにブックマークされます。

こんな感じで、「1人のユーザーは沢山のブックマークを作成」し、「1つの掲示板は複数のユーザーによりブックマークされる」というお互いに「1」に対して複数存在している関係を多対多の関係と言います。

中間モデル

多対多」のモデルの関係を定義するには、お互いの外部キー(foreign_key)を持っている必要があります。

このお互いの外部キー(foreign_key)を持っているテーブルのことを「中間テーブル」と言います。

テーブル

f:id:weblog_tec:20210424230733p:plain f:id:weblog_tec:20210424230749p:plain

テーブルの関係としては、

・usersテーブル

・boardsテーブル

・bookmarksテーブル

があります。

この中で、usersテーブルのidとboardsテーブルのidを外部キーとして持っている、

bookmarksテーブルが「中間テーブル」になります。

usersテーブルと、boardsテーブルの間に入って橋渡しをするテーブルのイメージです。

モデルのリレーション

モデル同士のリレーションは下記のとようになります。

# user.rb
class User < ApplicationRecord
    has_many :boards, dependent: :destroy
  has_many :comments, dependent: :destroy
  has_many :bookmarks, dependent: :destroy
end

Userは複数のboardやcommentやbookmarkができるので、has_manyで定義しています。

Userが消えたら、そのユーザーが作成したboardやcommentやbookmarkが消えるようにdependent: :destroyと指定しています。

# board.rb
class Board < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :bookmarks, dependent: :destroy
end
# bookmark.rb
class Bookmark < ApplicationRecord
  belongs_to :user
  belongs_to :board
end

その他のアソシエーションも同じ感じです。

validates :user_id, uniqueness: { scope: :board_id }とは?

ここからが本題になります。

先ほどのテーブルの中で、bookmarksテーブルのuser_idboard_idの組み合わせ重複がない形でないといけません。

ブックマーク処理を行うと「どのユーザーがどの掲示板をブックマークしているか」という関係性を表したレコードを作成します。

ブックマークを外す処理では、ユーザーと掲示板のブックマークの関係性がなくなるように該当のレコードを削除します。

もしuser_idboard_idの組み合わせが重複すると、ブックマークを外してもレコードが残り、ブックマークが解除できなくなってしまいます。

そのため、ユーザーと掲示板の組み合わせのレコードを一意にするためのunique制約が必要となります。

このユニークな組み合わせを担保するために、バリデーションを貼ります。

下記のように書きます。

class Bookmark < ApplicationRecord
    belongs_to :user
  belongs_to :board  
    validates :user_id, uniqueness: { scope: :board_id}
end

上記のように書くと、bookmarksテーブルのuser_idboard_idの組み合わせがユニークであるようにバリデーションチェックが行われます。

既に組み合わせが存在していた場合にはデータのinsert処理をRollBackされます。

uniquenessとscopeについて試してみる

validates :user_id, uniqueness: { scope: :board_id}

上記は、bookmarksテーブルのuser_idboard_idの組み合わせがユニークであるようにバリデーションチェックの記載でした。

本当にチェックされるかrails consoleで確認してみます。

# ユーザーを取得
> user = User.last
  SELECT  "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 4, last_name: "ユーザー", first_name: "D", created_at: "2021-03-12 16:49:31", updated_at: "2021-03-12 16:49:31">

# 掲示板を取得
> board = Board.first
  SELECT  "boards".* FROM "boards" ORDER BY "boards"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Board id: 1, title: "タイトル1", body: "本文1", user_id: 1, created_at: "2021-03-10 23:10:46", updated_at: "2021-03-10 23:10:46">

# Bookmarkモデルクラスのオブジェクトを作成
> bookmark = user.bookmarks.build(board_id: board.id)
=> #<Bookmark id: nil, user_id: 4, board_id: 1, created_at: nil, updated_at: nil>

# バリデーションチェックが行われて、INSERT文でデータが挿入されている
> bookmark.save
  SAVEPOINT active_record_1
  Board Load (0.1ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Bookmark Exists (0.5ms)  SELECT  1 AS one FROM "bookmarks" WHERE "bookmarks"."user_id" = ? AND "bookmarks"."board_id" = ? LIMIT ?  [["user_id", 4], ["board_id", 1], ["LIMIT", 1]]
  
    Bookmark Create (0.8ms)  INSERT INTO "bookmarks" ("user_id", "board_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 4], ["board_id", 1], ["created_at", "2021-04-06 14:06:56.875873"], ["updated_at", "2021-04-06 14:06:56.875873"]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

# もう一度同じユーザーで掲示板のブックマーク登録を試みる。
# バリデーションに引っ掛かり、INSERT文が走らない
> bookmark.save
   (0.1ms)  SAVEPOINT active_record_1
  Bookmark Exists (0.2ms)  SELECT  1 AS one FROM "bookmarks" WHERE "bookmarks"."user_id" = ? AND "bookmarks"."id" != ? AND "bookmarks"."board_id" = ? LIMIT ?  [["user_id", 4], ["id", 30], ["board_id", 1], ["LIMIT", 1]]
   (0.0ms)  RELEASE SAVEPOINT active_record_1
=> true

# 別のユーザーでブックマーク登録を試みる。
> user = User.second
  User Load (0.3ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 1], ["OFFSET", 1]]
=> #<User id: 2, last_name: "ユーザー", first_name: "B", email: "bbb@gmail.com", created_at: "2021-03-10 22:58:34", updated_at: "2021-03-10 22:58:34">

> bookmark = user.bookmarks.build(board_id: board.id)
=> #<Bookmark id: nil, user_id: 2, board_id: 1, created_at: nil, updated_at: nil>

# INSERT文が走って登録できる
> bookmark.save
   (0.1ms)  SAVEPOINT active_record_1
  Board Load (0.1ms)  SELECT  "boards".* FROM "boards" WHERE "boards"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Bookmark Exists (0.1ms)  SELECT  1 AS one FROM "bookmarks" WHERE "bookmarks"."user_id" = ? AND "bookmarks"."board_id" = ? LIMIT ?  [["user_id", 2], ["board_id", 1], ["LIMIT", 1]]
  Bookmark Create (0.1ms)  INSERT INTO "bookmarks" ("user_id", "board_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 2], ["board_id", 1], ["created_at", "2021-04-06 14:13:21.641085"], ["updated_at", "2021-04-06 14:13:21.641085"]]
   (0.0ms)  RELEASE SAVEPOINT active_record_1
=> true

DB側にunique indexを追加する方法

先ほどは、モデルでバリデーションのコードを書いて、INSERTするときにチェックするようにしました。

もう1つ一意制約の状態を担保する手段としてDB側にunique indexを追加する方法があります。

基本的にデータベース側とモデルとダブルチェックをするようにします。

class CreateBookmarks < ActiveRecord::Migration[5.2]
  def change
    create_table :bookmarks do |t|
      t.references :user, null: false, foreign_key: true
      t.references :board, null: false, foreign_key: true

      t.timestamps
    end

        add_index :bookmarks, [:user_id, :board_id], unique: true
  end
en

マイグレーションファイルに

add_index :bookmarks, [:user_id, :board_id], unique: true

の1文を追加することで、:bookmarksテーブルの:user_id, :board_idという2つのカラムの値の組み合わせはユニークであるように制約ができます。

まとめ

DBに保存するデータのユニーク状態を担保する方法は、

  1. アプリケーション側でvalidateを設けて制御する

  2. DB側でカラムに対して制約を設けて制御する

という2つの方法が存在します。

2つとも制約を付けるべきです。

制約を設けなかったが故に不正な値が存在してしまい可能性があるので、ダブルチェックで安全対策をしましょう。