11/08/2018, 21:41

モデル中の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 を通したり、まだまだやれる事はありますが、当初の目的は達成できました。

0