Help us understand the problem. What is going on with this article?

ビット演算を活用してRailsのモデルにColumn一本だけで複数のタグを付ける

TL;DR

権限判断

admin    = 1 # 0001
editor   = 2 # 0010
observer = 4 # 0100

my_roles = 3 # 0011

my_roles & admin    == admin    # true
my_roles & editor   == editor   # true
my_roles & observer == observer # false

# So `my_roles` Has `admin` and `editor`

Scopeで検索

user.rb
class User < ApplicationRecord
  # ...
  scope :has_role, lambda { |role|
    where('roles_code & ? = ?', role, role)
  }
  scope :roles_equal, lambda { |roles|
    where(roles_code: roles.reduce(:|))
  }
  # ...
end

# Eg:
# User.has_role('0001'.to_i(2)).has_role('0100'.to_i(2))
# > User Load (0.6ms)  SELECT "users".* FROM "users" WHERE (roles_code & 1 = 1) AND (roles_code & 4 = 4)
#
# User.roles_equal([1, 2])
# > User Load (4.9ms)  SELECT "users".* FROM "users" WHERE "users"."roles_code" = $1  [["roles_code", 3]]

はじめに

Railsでは、ユーザーに複数の権限や商品に複数のタグを付けたい時、has_and_belongs_to_manyを使って多対多のリレーションを作成することが多いと思います。
しかし多対多リレーションは中間テーブルを必要で、DBに必要以上多量なデータを保存してしまい、検索する際も非効率です。
そこでヒントになったのはUnix/Linuxの権限の持ち方でした。
*nix系システムでは、755, 600など、8進数3個でオーナ-グループその他のロールにそれぞれ8種類の権限を表示しています。
例えば権限600のファイルは「オーナのみ読み書きができる(rw-------)」を意味しています。
そしてIPアドレスも同じ考え方が用いられています。例えばサブネットを設定する時、255.255.255.0のようなサブネットマスクとオリジンのIPとビット演算することで、通信を制御しています。

ちなみにこれらの手法は基本情報技術者試験に出題されるので、ちょうど復習にもなれます。

方法

Modelの作成

整数型のroles_codeに、権限情報を保存するようにします。

class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string  :name,           null: false, default: ""
      t.integer :roles_code,     null: false, default: 0
    end
    add_index :users, :name
    add_index :users, :roles_code
  end
end
user.rb
class User < ApplicationRecord
end

権限の定義

例として権限を下記の風に定義します。各権限はそれぞれ独立です。
下記のテーブルが示したように、16進数(4ビット)と照らし合わしてみると、
最下位のビットはadminの権限の有無を表記、右から第2のビット、第3のビットはeditorobserverの有無を記載しています。最上位は使用していません。今後新たに権限が追加できるように、最下位から上の桁に追加するのは重要です。

権限名:  (未使用)  observer   editor   admin
ビット:      0        1         1        1

権限の認証

サブネットマスクのように、対象コードと指定のビットマスクとAND演算(論理積演算)を行うと、もし結果がマスクと同じであれば、該当マスクに「通過した」考え、該当マスクの権限を所有すると判断します。

例題

admin_mask    = 1 # 0001
editor_mask   = 2 # 0010
observer_mask = 4 # 0100

my_roles = 3 # 0011

#### 権限の認証 ####
my_roles & admin_mask    == admin_mask    # true
# 0011 & 0001 == 0001
my_roles & editor_mask   == editor_mask   # true
# 0011 & 0010 == 0010
my_roles & observer_mask == observer_mask # false
# 0011 & 0100 => 0000 != 0100
# 故に`my_roles` に `admin` と `editor` の権限があることがわかる


#### 権限の付与 ####
# `my_roles_2` に `editor` と `observer` の権限を付与したい
# 観察すると、`0110` にするとわかった。
# `editor_mask` と `observer_mask` のOR演算(論理和)をとればいい
my_roles_2 = editor_mask | observer_mask
# 0110 = 0010 | 0010
my_roles_2
# 6 => 0110

マスクの設定

user.rb
class User < ApplicationRecord
  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end

所有する全ての権限を表示

簡単に言えば、rolesというattr_accessorを自前で設定します。

user.rb
class User < ApplicationRecord
  @roles

  def roles
    @roles = []
    User.role_masks.each do |role, mask|
      if roles_code & mask == mask
        @roles << role_text
      end
    end
    @roles
  end

  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end
user = User.new(name: 'user1', roles_code: 1)
user.roles
# [:admin]

所有する権限の変更

user.rb
class User < ApplicationRecord
  @roles

  def roles
    @roles = []
    User.role_masks.each do |role, mask|
      if roles_code & mask == mask
        @roles << role
      end
    end
    @roles
  end

  def roles=(roles)
    self.roles_code = roles
      .map{|role| User.role_masks[role]}
      .compact.reduce(:|)
  end

  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end
user = User.new(name: 'user1', roles_code: 1)
user.roles
# [:admin]
user.roles = [:editor]
user.roles_code
# 2 => '0010'
user.roles = user.roles << :observer
user.roles_code
# 6 => '0110'

所有する権限で検索

SQLでは、WHERE文に簡単な計算が行うこともできるので、下記のscopeを設定すれば検索出来ます。

user.rb
class User < ApplicationRecord
  @roles

  scope :has_role, lambda { |role|
    role_code = User.role_masks[role]
    where('roles_code & ? = ?', role_code, role_code)
  }

  def roles
    @roles = []
    User.role_masks.each do |role, mask|
      if roles_code & mask == mask
        @roles << role
      end
    end
    @roles
  end

  def roles=(roles)
    self.roles_code = roles
      .map{|role| User.role_masks[role]}
      .compact.reduce(:|)
  end

  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end
User.has_role(:admin).has_role(:editor)
# User Load (0.6ms)  SELECT "users".* FROM "users" WHERE (roles_code & 1 = 1) AND (roles_code & 2 = 2)

enum風にrole?のようなメソッドの実装

メタプログラミングの手法を活用すれば、簡単にできます

user.rb
class User < ApplicationRecord
  @roles

  scope :has_role, lambda { |role|
    role_code = User.role_masks[role]
    where('roles_code & ? = ?', role_code, role_code)
  }

  role_masks.keys.each do |role|
    # `user.admin?` のようなメソッドの生成
    method_name = role.to_s + "?"
    define_method(method_name.to_sym) {
      self.roles.include?(role)
    }
  end

  def roles
    @roles = []
    User.role_masks.each do |role, mask|
      if roles_code & mask == mask
        @roles << role
      end
    end
    @roles
  end

  def roles=(roles)
    self.roles_code = roles
      .map{|role| User.role_masks[role]}
      .compact.reduce(:|)
  end

  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end
user = User.new(name: 'user1', roles_code: 3) # [:admin, :editor]
user.admin?    # true
user.editor?   # true
user.observer? # false

どうせなら、Enum風にScopeも自動生成しましょう。

user.rb
class User < ApplicationRecord
  @roles

  scope :has_role, lambda { |role|
    role_code = User.role_masks[role]
    where('roles_code & ? = ?', role_code, role_code)
  }

  role_masks.keys.each do |role|
    # `user.admin?` のようなメソッドの生成
    method_name = role.to_s + "?"
    define_method(method_name.to_sym) {
      self.roles.include?(role)
    }
    # `User.admin` のようなスコープの生成
    scope role, lambda {
      role_code = User.role_masks[role]
      where('roles_code & ? = ?', role_code, role_code)
    }
  end

  def roles
    @roles = []
    User.role_masks.each do |role, mask|
      if roles_code & mask == mask
        @roles << role
      end
    end
    @roles
  end

  def roles=(roles)
    self.roles_code = roles
      .map{|role| User.role_masks[role]}
      .compact.reduce(:|)
  end

  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end
User.admin.first
# User Load (2.6ms)  SELECT  "users".* FROM "users" WHERE (roles_code & 1 = 1) ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]

Ransackで検索可能にする(2019-07-03更新)

Ransackで検索したい時は、ransackable_scopesを活用すれば簡単にできます。
詳しくはこちらの記事を参考してください。

user.rb
class User < ApplicationRecord
  @roles

  scope :has_role, lambda { |role|
    role_code = User.role_masks[role]
    where('roles_code & ? = ?', role_code, role_code)
  }

  # ...省略...

  def self.ransackable_scopes(auth_object = nil)
    %i(has_role)
  end

  def self.role_masks
    {
      admin:    1, # 0001
      editor:   2, # 0010
      observer: 4, # 0100
    }
  end
end
User.ransack({has_role: 'admin'}).result
=> User Load (5.3ms)  SELECT "users".* FROM "users" WHERE (roles_code & 1 = 1)

参考

https://qiita.com/koara-local/items/185838ea3fa37d9007f7
https://qiita.com/lnznt/items/d2d18e45bcab48cf107d
https://qiita.com/okamos/items/724a4a162dfa9e27754a
https://db.just4fun.biz/?SQL/SELECT句で四則演算を行う
https://stackoverflow.com/a/14061680/11263491

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
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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