モデル中のSQLを綺麗に書く
きっかけ ActiveRecord 最高ですよね。ほとんどの事が SQL を記述しないで実現できることはもちろん、メソッドチェーンを使ってやりたい事を非常に簡潔に記述できます。 また、少し気を付けて実装するだけで、パフォーマンス問題が発生することも少ないです。 ですが、やはり例外はあるもので、どうしても SQL を記述しないといけない場面もあります。その時、どんなに綺麗に Ruby のコードを書いても、全く別の言語である SQL がコードに入ってくると、可読性が落ちます。 それを何とかできないか、と考えました。 ヒアドキュメント ...
きっかけ
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 を通したり、まだまだやれる事はありますが、当初の目的は達成できました。