きっかけ
ActiveRecord 最高ですよね。ほとんどの事が SQL を記述しないで実現できることはもちろん、メソッドチェーンを使ってやりたい事を非常に簡潔に記述できます。
また、少し気を付けて実装するだけで、パフォーマンス問題が発生することも少ないです。
ですが、やはり例外はあるもので、どうしても SQL を記述しないといけない場面もあります。その時、どんなに綺麗に Ruby のコードを書いても、全く別の言語である SQL がコードに入ってくると、可読性が落ちます。
それを何とかできないか、と考えました。
ヒアドキュメント
まず、考えるのがヒアドキュメントを利用する方法です。
(SQL はあくまで例です。こんな生 SQL を書いては ActiveRecord に失礼ですね。)
class User < ActiveRecord::Base
class << self
def created_on_today
sql = <<-EOS
SELECT * FROM users
WHERE created_at >= '%s' AND created_at <= '%s';
EOS
ActiveRecord::Base.connection.select sql % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
end
end
悪く無いですね。
ただ、Ruby のコードの中に SQL が混ざっているとどこまでが SQL なのかがパッと見分かりづらいです。
また SQL に無駄なインデントが入るのもちょっと気持ち悪いですね・・・。その両方を考慮すると、こうでしょうか?
class User < ActiveRecord::Base
class << self
def created_on_today
sql = <<-EOS
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
EOS
ActiveRecord::Base.connection.select sql % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
end
end
インデントをわざとずらすことで、ここが SQL だぞ、というのは一目で分かるようになりました。発行される SQL に無駄なインデントも入らないです。
でも、ここだけやたら1行が長かったりするのは、それはそれで気持ちが悪いです。
外部ファイル
だったら、SQL だけ Ruby のファイルの外に切り出せば良さそうです。
RailsConfig を使っている方は多いのでは無いでしょうか?例えば、RailsConfig を使うとこのように実装できますね。
sql:
users:
created_on_today:
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
class User < ActiveRecord::Base
class << self
def created_on_today
ActiveRecord::Base.connection.select sql.created_on_today % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
private
def sql
Settings.sql.users
end
end
end
モデルの中から SQL が排除できました。また、SQL が今後増えても比較的シンプルにモデルから呼び出すことができます。
ただ、モデルの実装を確認する時に、別のファイルを確認しないといけなくなりました。
__END__
と DATA
何とかうまくモデルの実装の中に Ruby と SQL を上手く分離して混在したいです。
余り使われ無いので、(少なくとも私の回りでは)すっかり忘れられている仕様ですが、Ruby のファイル内に Ruby 以外を記載するのに __END__
と DATA
が使えます。
詳しくは公式ドキュメントの項(constant Object::DATA)にありますが、その例を確認すると
print DATA.gets # => 故人西辞黄鶴楼
print DATA.gets # => 烟花三月下揚州
print DATA.gets # => 孤帆遠影碧空尽
print DATA.gets # => 唯見長江天際流
DATA.gets # => nil
__END__
故人西辞黄鶴楼
烟花三月下揚州
孤帆遠影碧空尽
唯見長江天際流
という動きをします。
これは使えそうです。早速、
class User < ActiveRecord::Base
class << self
def created_on_today
sql = DATA.gets
ActiveRecord::Base.connection.select sql % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
end
end
__END__
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
と書いてみます。
うん、同じファイルの中に Ruby と SQL が綺麗に分かれて書かれています。
これなら可読性も問題無いですね。
ただ、DATA.gets
だと SQL の記述の順番が固定されてしまうとイマイチなので、RailsConfig を使った時のように YAML を活用してみましょう。
class User < ActiveRecord::Base
class << self
def created_on_today
ActiveRecord::Base.connection.select sql["created_on_today"] % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
private
def sql
YAML.load(DATA)
end
end
end
__END__
created_on_today:
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
こうすれば、SQL を複数書いた時に、どの SQL かというのが分かり易いですね。
動きません
実は、上の例は動きません。
DATA
はプロセスを起動されたファイル(つまり $0
の中身)の __END__
以降が入るのであって、DATA
を参照しているファイルにある __END__
以降を読み取るのではありません。
そこで、DATA
の代わりに、__FILE__
を使って実行中のファイルの __END__
以降を読み取る、という作戦を使ってみます。
__FILE__
を使って内容を読む
自分自身のファイルの中身を取得し、その中に __END__
のみが記載されている行があれば、その行の後を取得し、YAML としてパースします。パースした結果はSQL_
という prefix を付けて、定数として登録します。
class User < ActiveRecord::Base
self_content = File.read __FILE__
data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
data.present? && (YAML.load(data).each do |name, string|
const_set "sql_#{name}".upcase, string
end)
class << self
def created_on_today
ActiveRecord::Base.connection.select SQL_CREATED_ON_TODAY % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
end
end
__END__
created_on_today:
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
なお、基本的に __END__
が2度記載されることは無い、という前提で処理を簡略化しています。
実際に、このコードを動かすと、期待した通りに動くことが確認できます。
定義した SQL は定数になるため、クラスがロードされるときに一度定義された後はオーバーヘッド無く呼び出すことができます。
期待通りに動いたので、これを他のモデルにも適用できると良いですね。Ruby を書くならば DRY を意識しないと。module にしましょう。
module化
この SQL の定義の方法は少なくとも、同じ Rails アプリケーション内では統一して利用したいです。
User
モデルは __END__
の後に SQL を書くけど、他のモデルではヒアドキュメントで書いている、というのは良く無いですね。
なので、SQL の定義部分を module として切り出して、全てのモデルで共有しましょう。処理を共有するのであれば、もちろん ActiveSupport::Concern の出番です。
class User < ActiveRecord::Base
include SqlDefiner
class << self
def created_on_today
ActiveRecord::Base.connection.select SQL_CREATED_ON_TODAY % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
end
end
__END__
created_on_today:
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
共通処理にしたい部分を外部に切り出し、それを include
します。
呼び出される側は、このようにしました。
module SqlDefiner
extend ActiveSupport::Concern
included do
self_content = File.read __FILE__
data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
data.present? && (YAML.load(data).each do |name, string|
const_set "sql_#{name}".upcase, string
end)
end
end
ActiveSupport::Concern を extend
して、切り出した処理を included
メソッドにブロックで渡します。こうすることで、この module をinclude
したクラスがこのブロックを実行してくれます。
またも動きません
この module を initializers 等から require
すれば動きそうですが、実は動きません。
__FILE__
が指すのは、上記の例だと app/models/user.rb
になって欲しいですが、常に lib/sql_definer.rb
になってしまうからです。
では、SQL を全て lib/sql_definer.rb
に書けば良いのでは?
いえいえ、それではここまで苦労した意味がありません。
だったら、設定ファイルに書けば良かったのですから。
別の方法を考えます。
included
メソッドを呼び出すのは・・・
included
メソッドのブロックは、この module をインクルードしたクラスが呼び出すはずです。つまり、このブロックの中で、自分を呼んだクラスが特定できれば、そのクラスの __END__
以降に記載されている内容を取得することができるはずです。
caller
メソッド
先程の lib/sql_definer.rb
の included
のブロックの中で、caller
を呼んでその呼びだし順を確認してみましょう。
["/Users/norifumi/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/activesupport-4.1.5/lib/active_support/concern.rb:120:in `class_eval'", "/Users/norifumi/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/activesupport-4.1.5/lib/active_support/concern.rb:120:in `append_features'", "/Users/norifumi/RailsApps/sampleapp/app/models/user.rb:2:in `include'", ...
ActiveSupport のバージョン等によって変わるかも知れませんが、3番目に app/models/user.rb
が入って居ることが確認できました。
これを利用して、先程の __FILE__
を書き直してみます。
module SqlDefiner
extend ActiveSupport::Concern
included do
self_content = File.read caller[2].split(":").first
data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
data.present? && (YAML.load(data).each do |name, string|
const_set "sql_#{name}".upcase, string
end)
end
end
__FILE__
を参照するのでは無く、caller
メソッドの結果の3番目のファイル名を取得して、そのファイルの内容を参照するようにしました。
これで動くようになりました。app/models/user.rb
だけでなく、他のモデルにinclude
しても期待通りの動きをしてくれます。
後は、このファイルを gem にでも纏めて、dependency として、ActiveSupport のバージョンを固定しておけば実用上問題無さそうです。
他のライブラリの実装に依存したくない
でも、何だか不完全な気がします。
以上でも十分実用に耐えられると思います。ActiveSupport のバージョンを変える時は、Rails 自体のバージョンを変える時でしょうし、その時は他の実装も色々と確認が必要な大きな変化でしょう。
ただ、やはりこの実装は気になります。ActiveSupport 内での処理順等が変わったら動かないことが確定している、というのは例え dependency を宣言していても、気持ちが良いものではありません。
caller
メソッドを使うアイデアは良さそうです。呼び出し元のファイルを取得できる、他の良い方法が無さそうですから。
問題なのは、それを included
という ActiveSupport::Concern が提供するメソッド内で利用していることです。ならば、include
しているモデル側から直接メソッドを呼ばせれば良いのでは無いでしょうか。
acts_as 型と呼ばれる機能拡張方法を参考に、以下のように実装してみます。
class User < ActiveRecord::Base
acts_as_sql_definer
class << self
def created_on_today
ActiveRecord::Base.connection.select SQL_CREATED_ON_TODAY % [
Time.now.beginning_of_day.utc.to_s(:db),
Time.now.end_of_day.utc.to_s(:db)]
end
end
end
__END__
created_on_today:
SELECT * FROM users WHERE created_at >= '%s' AND created_at <= '%s';
ポイントはこのメソッドを利用する側のモデルから、クラスマクロ(メソッド)を利用して caller
が含まれる処理を呼び出させることです。
module ActsAsSqlDefiner
extend ActiveSupport::Concern
module ClassMethods
def acts_as_sql_definer
self_content = File.read caller.first.split(":").first
data = self_content.split(/^__END__$/).last if self_content =~ /^__END__$/
data.present? && (YAML.load(data).each do |name, string|
const_set "sql_#{name}".upcase, string
end)
end
end
end
ActiveRecord::Base.send :include, ActsAsSqlDefiner
こうすると、確実に caller.first
に呼び出し元が設定できます。
後の処理は変わらず、これを Initializer でロードしておきましょう。
require "acts_as_sql_definer.rb"
ここまでできれば、同じ様に SQL を Ruby ファイルの末尾に書いて定義したいモデルのみ、
class Product < ActiveRecord::Base
acts_as_sql_definer
#...
__END__
select_all:
SELECT * FROM products;
のように、クラスマクロを呼び出します。
この機能が不要なモデルは acts_as_sql_definer
を呼び出さなければ処理が実行されません。
やっと上手くいきました。
コードをもっと綺麗に書きたい、という気持ちを簡単に実現できる、Rubyでのメタプログラミングはやはりとても楽しいですね!
gem にしたり、YAML でパースする前に erb を通したり、まだまだやれる事はありますが、当初の目的は達成できました。