バックエンド

ActiveRecordとMySQLで少し複雑な検索のRailsAPIを作る

MMM Corporation
mmmuser

最近社内の福利厚生で、「年間10万円を上限とし、業務上必要、開発環境の効率化や新しい知識習得に役立つと考えられる電子機器やグッズの購入が可能」という【業務効率化周辺グッズ購入補助】なるものが発足し、さっそく夜な夜なAmazonで美味しそうなITガジェットがないか探索している前田です。

弊社では現在フロントサイドはAngularJS、サーバーサイドはrailsでAPIとCMS、というアプリケーションを開発しております。
私はrailsのサーバーサイドのAPI担当ということで、開発の際に詰まったところなどを備忘録がてら書いていきたいと思います。
rails歴の短い私めの備忘録が、少しでも読んでくださった方のお役に立てれば幸いです。

それでは早速タイトルにある通り、中規模程度のデータ量が入ったデータベースから、色々な検索に対応するロジックを書いていきます。
例として、全国チェーンになっている本屋さんの本を、下記の要件を満たすように実装していきます。

####①本のタイトル、著者名、出版社、値段などで検索する。また、値段で並び替えする。
####②本の置かれている店舗名を表示させる。検索を店舗ごとの検索や、地域ごと、都道府県ごとの検索ができるようにしたい。
####③東京の在庫センターにある本がある。その場合は、所有店コードを基に店舗を指定する。
####④セールキャンペーンを行っている時があるが、キャンペーン中はセール価格を表示し、並び替えはセール価格を基に並び替えをする。

###準備

まずは環境を作ります。
下記のrails-apiを使って作っていきます。
https://github.com/rails-api/rails-api

railsアプリ作成

$ rails-api new BookSearch

下記サイトを参考にして頂くとすんなりセットアップできるかと思います。
http://railscasts.com/episodes/348-the-rails-api-gem?language=ja&view=asciicast

Gemfile

gem 'mysql2'

config/database.yml

default: &default
  adapter: mysql2
  encoding: utf8
  username: root
  password: pw
  host: 'localhost'
  pool: 5
  timeout: 5000
  charset: utf8

development:
  <<: *default
  database: dev_db

test:
  <<: *default
  database: test_db

production:
  <<: *default
  database: pro_db

データベース作成とmodel生成

$ bundle install
$ bundle exec rake db:create
$ bundle exec rails g model book
$ bundle exec rails g model shop
$ bundle exec rails g model campaign
$ bundle exec rails g model campaign_book

migrationファイル修正

class CreateBooks < ActiveRecord::Migration
  def change
    create_table :books do |t|
      t.string :title
      t.string :author
      t.string :publisher
      t.integer :price
      t.string :shop_id
      t.string :stock_shop_id
      t.timestamps
    end
  end
end
class CreateShops < ActiveRecord::Migration
  def change
    create_table :shops do |t|
      t.string :name
      t.integer :prefecture
      t.integer :area
      t.timestamps
    end
  end
end
class CreateCampaigns < ActiveRecord::Migration
  def change
    create_table :campaigns do |t|
      t.string :title
      t.date :start_date
      t.date :end_date
      t.timestamps
    end
  end
end
class CreateCampaignBooks < ActiveRecord::Migration
  def change
    create_table :campaign_books do |t|
      t.integer :campaign_id
      t.integer :book_id
      t.integer :sale_price
      t.timestamps
    end
  end
end

マイグレート

$ bundle exec rake db:migrate

以上で準備が出来ました。

###①本のタイトル、著者名、出版社、値段などで検索する。また、値段で並び替えする。
####ポイント・・・・・scopeの中でreturn if 〜を使う

この実装を行う時に、最初はコントローラーに恥ずかしながら下記のような書き方をしてしまっていました。

app/controller/api/search_book_controller.rb

module Api
  class BookController < ApiController
  def index
    exec_query = 'Book'
    exec_query += 'where("title = ?", params[:title])' if params[:title].present?
    exec_query += 'where("author = ?", params[:author])' if params[:author].present?
    exec_query += 'where("publisher = ?", params[:publisher])' if params[:publisher].present?
    exec_query += 'where("price <= ?", params[:price])' if params[:price].present?
    exec_query += 'order("price")' if params[:order].present? && params[:order] == 'price'

    @books = eval(exec_query)
    render
  end
end

パラメーターがある時はexec_queryの文字列の中に検索条件を組み込んでいき、最後にevalで実行する、というやり方です。

しかし、これではすごく見づらくなってしまいますし、今後、機能が追加される時もどんどん実装が難しくなっていくし、何よりもrailsの書き方ではない、と偉大な諸先輩方の指摘を受けました。
scopeを使って実装していったらスッキリ書けるよ、ということで、scopeで書いていくことにしました。
モデルにロジックを書くと、別のコントローラーでも同じロジックを使えるというメリットなどもあります。
ということで書いてみたのがこちらです。

app/model/book.rb

class Book < ActiveRecord::Base
  scope :serch_books, lambda { |params|
    search_with_title(params[:title])
    search_with_author(params[:author])
    search_with_publisher(params[:publisher])
    search_with_price(params[:price])
    order_by_price(params[:order])
  }

  scope :search_with_title, lambda { |title|
    return if title.nil?
    where('title = ?', title)
  }

  scope :search_with_author, lambda { |author|
    return if author.nil?
    where('author = ?', author)
  }

  scope :search_with_publisher, lambda { |publisher|
    return if publisher.nil?
    where('publisher = ?', publisher)
  }

  scope :search_with_price, lambda { |price|
    return if price.nil?
    where('price <= ?', price)
  }

  scope :order_by_price, lambda { |order|
    return if order.nil? && order == 'price'
    order('price')
  }
end

app/controller/api/search_book_controller.rb

module Api
  class SearchBookController < ApiController
  def index
    @books = Book.search_books(params)
    render
  end
end

モデル側は少し多くなりましたがコントローラー側が見事にスッキリしましたね。

scopeで書いた最大のメリットはパラメーターがnilの時に、処理を抜けて、次の処理を実装してくれる、というところにあると思います。
これはlambdaの特性だと思いますが、procの中でreturnすると、メソッド全体の処理から抜けてしまうのに対し、lambdaはその処理から抜けて、次の処理を実行しにいくという特性があるので、こういったことが実現出来ています。
こちらのページが分かりやすかったです。

http://blog.livedoor.jp/sasata299/archives/51488276.html

lambdaは果たして本当にそうなっているのか、クラスメソッドと比較してみました。
scopeと同じ実装のクラスメソッドを作ります。

  def.self method_search_with_title
    return if title.nil?
    where('title = ?', title)
  end

rails consoleで実行してみます。

2.1.2 :003 > Book.method_search_with_title('よくわかるRuby on Rails')
  Book Load (0.6ms)  SELECT `books`.* FROM `books`  WHERE (title = 'よくわかるRuby on Rails')
 => #<ActiveRecord::Relation [#<Book id: 1, title: "よくわかるRuby on Rails", author: "山田 太郎", publisher: "山田出版", price: 2000, shop_id: 1, stock_shop_id: nil, created_at: nil, updated_at: nil>]>

値を入れて渡した時は、意図したクエリを発行し、ActiveRecord::Relationが返ってきました。
ではnilを入れてみます。

2.1.2 :005 > Book.method_search_with_title(nil)
 => nil
2.1.2 :006 >

当たり前ですがnilが返ってきました。

scopeで同じことをやってみます。

2.1.2 :006 > Book.search_with_title('よくわかるRuby on Rails')
  Book Load (0.9ms)  SELECT `books`.* FROM `books`  WHERE (title = 'よくわかるRuby on Rails')
 => #<ActiveRecord::Relation [#<Book id: 1, title: "よくわかるRuby on Rails", author: "山田 太郎", publisher: "山田出版", price: 2000, shop_id: 1, stock_shop_id: nil, created_at: nil, updated_at: nil>]>

nil

 2.1.2 :008 > Book.search_with_title(nil)
  Book Load (0.3ms)  SELECT `books`.* FROM `books`
 => #<ActiveRecord::Relation [#<Book id: 1, title: "よくわかるRuby on Rails", author: "山田 太郎", publisher: "山田出版", price: 2000, shop_id: 1, stock_shop_id: nil, created_at: nil, updated_at: nil>]>

nilで実行すると、SELECT books.* FROM booksが実行されていますね。
これは、Book.allと同じ動作をしています。
scopeはActiveRecord::Relationが戻ってきているのに対し、メソッドではnilが返ってきました。
ということはメソッドの場合はパラメーターが存在しない時の処理を、メソッドチェインしての実装はできない、ということですね。
nilに対して実行するとno methodエラーになりますので。

ということで、scopeは非常に有用だということが実証できたかと思います。

ちなみにですが、scopeの中でscopeを呼び出したり、メソッドを呼び出したりが出来ます。
メソッドを呼び出す時は、そのメソッドはクラスメソッドでなければなりません。
scopeの中で最終的にActiveRecord::Relationを返すようにすれば大丈夫です。

これで、①の実装が出来ました。

###②本の置かれている店舗名を表示させる。検索を店舗ごとの検索や、地域ごと、都道府県ごとの検索ができるようにしたい
ポイント・・・・・left joinを使う

MySQLにはleft joinやright join、inner joinなどがありますが、今回はleft joinです。
本来はhas_manyやbelongs_toなどで関連付けして、include、predecateで実装していくのがrailsの王道だと思いますが、今回のDBはMySQL限定だということや、この後の複雑なクエリに対応する、という事も考えてjoinsを使って実装していきます。

ここはそれほど難しくなないのですが、必要なカラムをselectして、joinsでleft joinをsql文で書くだけです。

app/model/book.rb

  scope :select_columns, lambda {
    select('books.*, shops.name as shop_name')
  }

  scope :join_tables, lambda {
    joins('left join shops
            on shops.id =
            books.shop_id')
  }

  scope :search_with_shop, lambda { |shop|
    return if shop.nil?
    where('shop_id = ?', shop)
  }

  scope :search_with_area, lambda { |area|
    return if area.nil?
    where('shops.area = ?', area)
  }

  scope :search_with_pref, lambda { |pref|
    return if pref.nil?
    where('shops.prefecture = ?', pref)
  }

そしてscope :search_booksの中に上記scopeを含めます。
これで店舗名を表示させたり、店舗やエリアや都道府県を指定しての検索が出来るようになりました。

コンソールで試してみます。

パラム作成

2.1.2 :001 > params = {pref: 1}
 => {:pref=>1}

booksに結果を代入

2.1.2 :004 > books = Book.search_books(params)
  Book Load (0.9ms)  SELECT books.*, shops.name as shop_name FROM `books` left join shops
             on shops.id =
             books.shop_id WHERE (shops.prefecture = 1)
 => #<ActiveRecord::Relation [#<Book id: 1, title: "よくわかるRuby on Rails", author: "山田 太郎", publisher: "山田出版", price: 2000, shop_id: 1, stock_shop_id: nil, created_at: nil, updated_at: nil>]>

shop_nameで店舗名を取得

2.1.2 :005 > books[0].shop_name
 => "本屋 東京店"

動的にActiveRecord::Relationオブジェクトにメンバを追加してくれるので、非常に便利ですね。

###③東京の在庫センターにある本がある。その場合は、所有店コードを基に店舗を指定する。
####ポイント・・・・・MySQLのif文を使う
ちょっと要件が説明不足でしたが、前提として、booksのshop_idカラムが0の時、その本は本部の在庫にあるので、booksのstock_shop_idを基に、shopテーブルと紐づける、ということです。
想定としては、その本の所有は店舗にあるが、本部からまだ未発送の場合など、です。

ここにはちょっとハマりました。
最初はunion allを使おうと思っておりました。
sql文は下記のようなsql文です。

mysql> select books.*,shops.name as shop_name from books left join shops on books.shop_id = shops.id where shops.id is not null
    -> union all
    -> select books.*,shops.name as shop_name from books left join shops on books.stock_shop_id = shops.id where shops.id is not null;
+----+------------------------------+-----------------+--------------+-------+---------+---------------+------------+------------+--------------------+
| id | title                        | author          | publisher    | price | shop_id | stock_shop_id | created_at | updated_at | shop_name          |
+----+------------------------------+-----------------+--------------+-------+---------+---------------+------------+------------+--------------------+
|  1 | よくわかるRuby on Rails      | 山田 太郎      | 山田出版     |  2000 |       1 |          NULL | NULL       | NULL       | 本屋 東京店       |
|  2 | よく分かるSQL                | 田中 次郎      | 田中文庫     |  3000 |       0 |             1 | NULL       | NULL       | 本屋 東京店       |
+----+------------------------------+-----------------+--------------+-------+---------+---------------+------------+------------+--------------------+
2 rows in set (0.01 sec)

sqlだと問題なく実行でき、期待通りの結果が返ってきます。
問題はrailsで実装する時でした。

どうやってもActiveRelationの型で返ってきません。
これは実装全体を見直さなければならないのか?とあきらめかけましたが、sqlのif文で実装出来るかもと思い試してみたら実装できました。

  scope :join_tables, lambda {
    joins('left join shops
            on shops.id =
            if(books.shop_id = 0,
            books.stock_shop_id,
            books.shop_id)')
  }

rails consoleで試してみます。

2.1.2 :001 > books = Book.search_books({})
  Book Load (3.4ms)  SELECT books.*, shops.name as shop_name FROM `books` left join shops
             on shops.id =
             if(books.shop_id = 0,
             books.stock_shop_id,
             books.shop_id)
 => #<ActiveRecord::Relation [#<Book id: 1, title: "よくわかるRuby on Rails", author: "山田 太郎", publisher: "山田出版", price: 2000, shop_id: 1, stock_shop_id: nil, created_at: nil, updated_at: nil>, #<Book id: 2, title: "よく分かるSQL", author: "田中 次郎", publisher: "田中文庫", price: 3000, shop_id: 0, stock_shop_id: 1, created_at: nil, updated_at: nil>]>
2.1.2 :002 > books[0].title
 => "よくわかるRuby on Rails"
2.1.2 :003 > books[0].shop_name
 => "本屋 東京店"
2.1.2 :004 > books[1].title
 => "よく分かるSQL"
2.1.2 :005 > books[1].shop_name
 => "本屋 東京店"

unionで繋いだ時と同じように取得出来ていることが確認出来ました。

sqlのunion文は、つなぐ分だけクエリを発行することになるので、パフォーマンス的にもif文で実装するほうが優れているので結果的には良かったです。

他の場面でもsqlのif文は結構使えて約に立ちます。

###④セールキャンペーンを行っている時があるが、キャンペーン中はセール価格を表示し、並び替えはセール価格を基に並び替えをする。
####ポイント(とゆうか力技)・・・sqlを埋め込む

これは今までの複合技のようなことをします。
下記の3つの手順に分けて実装しました。
#####1、現在開催中のすべてのキャンペーンのidをピックアップする
#####2、ピックアップしたキャンペーンに、booksモデルを結びつけて、キャンペーンがある時はキャンペーン価格を、無い時は通常の価格を取得するカラムを作る。
#####3、そのカラムでorderをする

最終的に完成したコードは下記の通りです。

app/model/book.rb

class Book < ActiveRecord::Base
  scope :serch_books, lambda { |params|
    select_columns
    .join_tables
    .search_with_title(params[:title])
    .search_with_author(params[:author])
    .search_with_publisher(params[:publisher])
    .less_than_price(params[:min_price])
    .greater_than_price(params[:max_price])
    .search_with_shop(params[:shop])
    .search_with_area(params[:area])
    .search_with_pref(params[:pref])
    .order_by_price(params[:order])
  }

  scope :select_columns, lambda {
    campaign_ids = Campaign.where('? between start_date and end_date', Time.now).pluck(:id)
    select("books.*,
            shops.name as shop_name,
            if((campaign_books.sale_price is not null) and (campaign_books.campaign_id in (
            #{campaign_ids.empty? ? '0' : campaign_ids.join(',')}
            )), campaign_books.sale_price, books.price) as least_price")
  }

  scope :join_tables, lambda {
    joins('left join shops
            on shops.id =
            if(books.shop_id = 0,
             books.stock_shop_id,
             books.shop_id)')
    .joins('left join campaign_books
            on campaign_books.book_id =
            books.id')
  }

  scope :search_with_title, lambda { |title|
    return if title.nil?
    where('title in (?)', title)
  }

  scope :search_with_author, lambda { |author|
    return if author.nil?
    where('author in (?)', author)
  }

  scope :search_with_publisher, lambda { |publisher|
    return if publisher.nil?
    where('publisher in (?)', publisher)
  }

  scope :less_than_price, lambda { |price|
    return if price.nil?
    where('price >= ?', price)
  }

  scope :greater_than_price, lambda { |price|
    return if price.nil?
    where('price <= ?', price)
  }

  scope :order_by_price, lambda { |order|
    return if order.nil? && order == 'price'
    order('least_price')
  }

  scope :search_with_shop, lambda { |shop|
    return if shop.nil?
    where('shop_id in (?)', shop)
  }

  scope :search_with_area, lambda { |area|
    return if area.nil?
    where('shops.area in (?)', area)
  }

  scope :search_with_pref, lambda { |pref|
    return if pref.nil?
    where('shops.prefecture in (?)', pref)
  }
end

ついでに他のカラムも配列で検索出来るようにしました。

###感想

もっとスマートなやり方などもあるのかもしれません。
もし良いノウハウなどがございましたら是非お教え下さいませ。
では良きRails Lifeを。

Ruby on Railsを活用したWebサービスや業務システム開発をご検討の企業様は、是非MMMにご相談下さいませ!

AUTHOR
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社はアマゾン ウェブ サービス(AWS)に 専門性や実績を認定された公式パートナーです。
記事URLをコピーしました