17
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

WHERE を使わない絞り込み

Last updated at Posted at 2015-12-18

概要

SQL で、行を絞り込むのに使えるのは WHERE だけではなく、INNER JOIN を使う事もできるという性質を利用して、モデルの絞り込みをしてみようという話です

背景

先日SQL 脳から見た Rubyという発表をして、オチの部分に Rails のサンプルコードを出したんですが、なにぶん時間がギリギリだったので、駆け足になってしまい、見ている人が「???」という感じになっていたので、記事にしたいとおもいます

実装

登場するモデル

User

下記のようなスキーマ

  • name: text
  • birthday: date

サンプルデータ

name birthday
ちゆ 1999-02-14
ちゆ 2003-02-14
もなみ 2002-02-09
もなみ 2006-02-09
メモリ 2003-11-02
メモリ 2004-11-02

シチュエーション

  • たとえば「ちゆ12歳」と「もなみ9歳」だけを抜き出したいとする
SELECT *
  FROM users
 WHERE (name = 'ちゆ' AND date_part('year', age(birthday)) = 12)
    OR (name = 'もなみ' AND date_part('year', age(birthday)) = 9)
  • SQL で書くと、こんなかんじ
  • 対象が増える程 OR が増える
  • Repeat myself 感(Not DRY)
  • Arel を駆使してゴリゴリ書くアレだ...
  • 今回は、こういうのじゃない SQL を生成する実装にしてみよう

モデルを追加

Shapeshifter

class CreateShapeshifters < ActiveRecord::Migration
  def change
    create_table :shapeshifters do |t|
    end

    # INSERT forbidden
    execute <<~SQL
         ALTER TABLE shapeshifters
      ADD CONSTRAINT forbid_to_insert_shapeshifters
               CHECK (false)
    SQL
  end
end
class Shapeshifter < ActiveRecord::Base
  def self.metamorphose(schema:, tuples:)
    with(table_name => sanitize_sql_array(<<~SQL.chomp, tuples.flatten))
      SELECT *
        FROM #{to_values(tuples)}
          AS t(#{primary_key}, #{schema.join(', ')})
    SQL
  end
 
  def self.to_values(tuples)
    tuples.
      map.with_index(1) { |tuple, i| "(#{i}, #{tuple.map { '?' }.join(', ')})" }.
      join(", ").
      tap { |values| break "(VALUES #{values})" }
  end
 
  def self.sanitize_sql_array(sql, values)
    ActiveRecord::Base.send(:sanitize_sql_array, [sql, *values])
  end
end

Shapeshifter の性質

Shapeshifter.all
#=> #<ActiveRecord::Relation []>
# 中身は常に空

Shapeshifter.create
#=> ActiveRecord::StatementInvalid:
#=> PG::CheckViolation: ERROR:  new row for relation "shapeshifters"
#=> violates check constraint "forbid_to_insert_shapeshifters"
# 新しいレコードは作成出来ない
  • 中身も無い、INSERT も出来ない、属性が id のみのモデル
  • これをこうじゃ
ar = Shapeshifter.
  metamorphose(
    schema: %w{code message},
    tuples: [[404, 'Not Found'], [200, 'OK']]
  )
#=> #<ActiveRecord::Relation [#<Shapeshifter id: 1>, #<Shapeshifter id: 2>]>

ar.map(&:attributes)
#=> [{"id"=>1, "code"=>404, "message"=>"Not Found"},
#=>  {"id"=>2, "code"=>200, "message"=>"OK"}]

ar.where(code: 200)
#=> #<ActiveRecord::Relation [#<Shapeshifter id: 2>]>
  • アプリケーションから注入したタプルが SQL 界を旅してから AR として戻ってくる
-- 発行される SQL
  WITH "shapeshifters" AS (
SELECT *
  FROM (VALUES (1, 404, 'Not Found'), (2, 200, 'OK'))
    AS t(id, code, message) )
SELECT "shapeshifters".*
  FROM "shapeshifters"
--  id | code |  message  
-- ----+------+-----------
--   1 |  404 | Not Found
--   2 |  200 | OK
  • 一時的に共通表式(CTE)を参照することで、これを実現しています

User モデル

class User < ActiveRecord::Base
  scope :on_name_and_ages, -> (tuples) {
    with(
      target_users: Shapeshifter.
        metamorphose(schema: %w{name age}, tuples: tuples)
    ).
    joins(<<~SQL)
      JOIN target_users
        ON users.name = target_users.name
       AND age = date_part('year', age(birthday))::integer
    SQL
  }
end
  • scope に Shapeshifter を仕込むと、scope を呼び出す度に、引数に渡したタプルの内容のテーブルがあるかのような状態でクエリーが実行出来るので、それを JOIN することで絞り込みの目的を達成する
User.on_name_and_ages(
  [['ちゆ', 12], ['もなみ', 9]]
).map(&:attributes)
#=> [{"id"=>2, "name"=>"ちゆ", "birthday"=>Fri, 14 Feb 2003},
#=>  {"id"=>4, "name"=>"もなみ", "birthday"=>Thu, 09 Feb 2006}]
  • タプルを使った絞り込みをする scope になっていることが分かる
-- 発行される SQL
  WITH "target_users" AS (
      WITH "shapeshifters" AS (
    SELECT *
      FROM (VALUES (1, 'ちゆ', 12), (2, 'もなみ', 9))
        AS t(id, name, age))
SELECT "shapeshifters".*
  FROM "shapeshifters")
SELECT "users".*
  FROM "users"
  JOIN target_users
    ON users.name = target_users.name
   AND age = date_part('year', age(birthday))::integer

まとめ

  • 普段は WHERE とか IN を使えば済むけれども、(... AND ...) OR (... AND ...) ... みたいな絞り込み方をしないといけないケースで、そういう SQL を生成するコードを書くのもしんどいし、生成された SQL を読むのもしんどい
  • WITH を使って生成したリレーションを JOIN する、今回の方式を使うと、入り組んだ WHERE の方式と比べて、絞り込みに使う「データ」と絞り込み方の「ロジック」が分離されて、コードも、生成された SQL も分かりやすくなるように思う
  • いつか使いこなしたい

12 月 18 日

今日はわしの誕生日なんですが、毎年「ウィッシュリストに入れてるからには読めよ」などと言いながら難しい本を送りつけて下さる方が続出しております。ほんと勘弁して欲しいですね

17
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?