Railsで自由自在なSQLが組み上がるまで


ActiveRecord / Arel

以下の続編としてお読みください
Railsの複雑な検索はスコープを使おう
RailsでSQL文をどんどん部品化していきます

今回DatabaseがRedshift(Postgresqlに似ている)です

Arel::Nodes::NamedFunction

DB固有の関数を使う場合などはこれ

# こんな感じのテーブルがあるよ
# create table timeaxis (time_s timestamp)
time_table = Arel::Table.new('timeaxis')
time_table.project(
  Arel::Nodes::NamedFunction.new(
    'date_trunc',
    [Arel::Nodes::build_quoted('day'),
     time_table[:time_s]]
  ).as('daily')
).group(
  Arel::Nodes::NamedFunction.new(
    'date_trunc',
    [Arel::Nodes::build_quoted('day'),
     time_table[:time_s]]
  )
)

# 得られるSQL
# SELECT
#   date_trunc('day', "time_table"."time_s") AS daily
# FROM
#   "time_table"
# GROUP BY
#   date_trunc('day', "time_table"."time_s")

タイムスタンプを日次にする例、scopeとして宣言しておけば、dayの部分を引数にして1時間ごと、月ごとなど変更は容易です

NamedFunctionは第一引数を関数名として、第二引数に配列(中身はArelオブジェクト)をとりカンマ区切りでつなぐようです。第二引数の配列内には2つを超えるオブジェクトも渡せます

build_quotedは文字列をシングルクオートで囲むみたい

PostgresqlのCASTをスコープに設定するなら

scope :cast, lambda { |str, type|
  Arel::Nodes::NamedFunction.new(
    'CAST', [
      Arel::Nodes::As.new(
        Arel::Nodes.build_quoted(str),
        Arel::Nodes::SqlLiteral.new(type)
      )
    ]
  )
}

# cast('20.2', 'float') => CAST('20.2' AS float)
# cast('1 HOUR', 'interval') => CAST('1 HOUR' AS interval)

文字列と変換する型を引数として渡せば型変換してくれます

Asは二つの引数をASでつないでくれるみたい
SqlLiteralは渡した文字列をArelオブジェクトにしてくれる

Arel::Nodes::InfixOperation

あまり使い道は見いだせませんが、こんなことも可能です

Arel::Nodes::NamedFunction.new(
  'SELECT',
  [Arel::Nodes::InfixOperation.new(
    '+',
    Arel::Nodes::NamedFunction.new(
      'CAST', [
        Arel::Nodes::As.new(
          Arel::Nodes.build_quoted('2015-08-01'),
          Arel::Nodes::SqlLiteral.new('TIMESTAMP')
        )
      ]
    ),
    Arel::Nodes::InfixOperation.new(
      '*',
      Arel::Nodes::NamedFunction.new(
        'GENERATE_SERIES', [0, 23]
      ),
      Arel::Nodes::NamedFunction.new(
        'CAST', [
          Arel::Nodes::As.new(
            Arel::Nodes.build_quoted('1 HOUR'),
            Arel::Nodes::SqlLiteral.new('INTERVAL')
          )
        ]
      )
    )
  )]
).as('hourly')

# 得られるSQL
# SELECT
#    (CAST('2015-08-01' AS TIMESTAMP) + GENERATE_SERIES(0, 23)
#    *
#    CAST('1 HOUR' AS INTERVAL))
# AS hourly

結局1時間ごとを得るのかよ...

InfixOperationは第一引数にオペレータをとり、第二引数と第三引数をつなぐみたい

意味は無いが、下記のようにも使える

Arel::Nodes::NamedFunction.new(
  'CAST', [
    Arel::Nodes::InfixOperation.new(
      'AS',
      Arel::Nodes.build_quoted('2015-08-01'),
      Arel::Nodes::SqlLiteral.new('TIMESTAMP')
    )
  ]
)

# CAST('2015-08-01' AS TIMESTAMP)

先述の通りAsを使うのが良いと思う
なにより、Arelでは第一引数をオペレータとして扱うので不具合の原因となりそうなので非推奨

まとめ

Arel::Nodesを利用すればArelで用意されていない関数なども使うことができるようになります
また本投稿では全てのモジュールを扱っておりませんので、下記を参考ください
Module: Arel::Nodes